---
name: guofeng-threejs
description: 国风 3D 网页渲染。当用户要"水墨风 3D""中国风网页特效""Three.js 水墨""国风 H5""青绿山水 3D""非真实感渲染 NPR""古风 WebGL"时使用。提供可运行的中式渲染 shader 与实现方法，不是通用 Three.js 教程。
metadata:
  author: sanhuang520-ship-it
  category: development
  tags: development, design, animation, chinese
---

# 国风 Three.js 渲染

**定位说明**：通用 Three.js 教程已经很多（英文社区有大量高质量资源）。
这个技能只做一件他们不做的事——**中式美学的实时渲染**。

## 什么时候用

中国风官网 / 文旅 H5 / 品牌活动页 / 水墨风交互 / 国风游戏原型 / 非真实感渲染（NPR）。

**不适用**：通用 3D 建模、物理仿真、写实渲染——那些去看 Three.js 官方文档更好。

## 先问清楚

1. **载体**（PC 官网 / 手机 H5 / 微信内嵌）—— 决定性能预算
2. **要哪种中式质感**（水墨 / 青绿山水 / 敦煌壁画 / 剪纸皮影）
3. **有没有模型**（有 glTF？还是用几何体？）
4. **动效需求**（自动旋转 / 跟随鼠标 / 滚动驱动）

> ⚠️ **移动端警告**：Three.js + 自定义 shader 在中低端安卓上会明显发热掉帧。
> H5 项目务必先确认目标机型，必要时降级为静态图。

## 水墨渲染的三个核心

水墨效果不是加个滤镜，是**三件事的组合**：

### 1️⃣ 墨分五色 —— 色阶量化

真实光照是连续的，水墨是**分层**的。把明暗量化成 5 阶：

```glsl
float lam = max(dot(N, normalize(uLight)), 0.0);
float steps = 5.0;
float q = floor(clamp(lam, 0.0, 1.0) * steps) / (steps - 1.0);
vec3 col = mix(uInk, uPaper, q);   // 浓墨 → 纸白
```

> 阶数太多（>8）失去水墨感，太少（<3）像色块。**5 阶是甜点**。

### 2️⃣ 边缘积墨 —— rim 压深

毛笔在轮廓处停留更久，墨更浓。用视线与法线夹角模拟：

```glsl
float rim  = 1.0 - max(dot(N, normalize(vV)), 0.0);
float edge = smoothstep(0.45, 0.95, rim);
col = mix(col, uInk, edge * 0.85);
```

### 3️⃣ 笔触扰动 —— 噪声打破机械感

纯数学量化的边界太整齐，不像手绘。**在量化前扰动光照值**：

```glsl
float n = noise(vP * 5.5 + uTime * 0.06);
lam += (n - 0.5) * 0.16;          // 扰动幅度别超过 0.2
```

再叠一层细噪声当宣纸颗粒：

```glsl
col *= 0.97 + 0.03 * noise(vP * 40.0);
```

## 青绿山水：和工笔只差一个参数

这个方案**没有引入任何新技术**——描边用的是和 `gongbi-demo.html` 完全同一套反向外壳，
连代码都一样。唯一的差别是 `uLine` 传进去的是**暖金**而不是墨黑。

但气质完全不同。demo 里的「墨线对照」档就是为了证明这件事：
同样的山、同样的青绿配色，只换线色，「金碧山水」的贵气立刻消失。
**「金碧山水」这个名字，就是从那条金线来的。**

### 石青石绿要按海拔映射，不是按明暗

这是青绿山水的固定语法：**山脚石绿、山顶石青**。不是随机分色，也不是用明暗分：

```glsl
// 顶点着色器里把世界坐标传出来
vW = (modelMatrix * vec4(position, 1.0)).xyz;

// 片元着色器：按 y 取海拔，归一化范围要贴合实际山高
float alt = clamp((vW.y + 1.45) / 3.05, 0.0, 1.0);
alt = smoothstep(0.08, 0.92, alt);
vec3 base = mix(uLow, uHigh, alt);   // uLow=石绿, uHigh=石青
```

⚠️ **归一化范围一定要贴合实际模型高度**。初版系数没对上，`alt` 大部分时间挤在中段，
青绿两色根本分不开，看着就是一片墨绿。

### 金不只在线上，也在山脊

除了描边，受光的山脊再敷一层金粉，「金碧」才立得住：

```glsl
float ridge = smoothstep(0.72, 0.98, lam) * smoothstep(0.35, 0.8, alt);
col = mix(col, uLine, ridge * uGold * 0.42);
```

`uGold = 0` 时这条完全不生效，正好用来做墨线对照档。

### 又栽了一次的坑

**暗部下限压太狠**。初版 `mix(base * 0.55, base, q)`，矿物色的饱和度全被吃掉，
整片山糊成墨绿——**和敦煌那次是同一个错误**。改成 0.78 才对。
青绿山水（尤其《千里江山图》）以鲜艳著称，把它画暗就丢了根本特征。

水面也一样：初版 11×6 的平面四条硬边全在画面里，像一块灰绿托盘；
放大到 46×34 让边缘出画，再把不透明度压到 0.13 —— 青绿山水的水多是
**留白加一点淡色**，让绢底透上来才对。

## 敦煌壁画：让它好看的是破坏，不是配色

前三个方案（水墨、工笔、剪纸）里噪声都是**配角**——用来打破机械感的微扰。
敦煌反过来：**噪声是主角**，幅度大一个量级。

一句话概括这个方案：**敦煌壁画好看，很大程度上是因为它旧了。**
把做旧关掉，剩下的只是三色平涂，一点特征都没有。demo 里的「初绘」档就是这个对照——
同一套 shader、同样的配色，只把 `uAge` 从 1.0 降到 0.15，敦煌感立刻消失。

### 1️⃣ 多倍频噪声，不是单层

剥落是自然破坏，天然多尺度。单层噪声一眼假，要叠：

```glsl
float fbm(vec3 p){
  float s = 0.0, a = 0.5;
  for (int i = 0; i < 4; i++) { s += a * noise(p); p *= 2.03; a *= 0.5; }
  return s;
}
```

然后按尺度分工：大块剥落 `fbm(vP*3.4)`、中等起甲 `fbm(vP*11.0)`、颗粒 `noise(vP*46.0)`。

### 2️⃣ 剥落 = 露出下面的地仗

不是把颜色调暗，是**颜料层没了、露出白垩地仗**。所以是往地仗色混，不是往黑里混：

```glsl
float loss = smoothstep(0.52, 0.34, flake * 0.65 + mottle * 0.35);
col = mix(col, uPlaster, clamp(loss * uAge, 0.0, 0.92));
```

龟裂用噪声的**等值线**取细缝（两个反向 smoothstep 相乘）：

```glsl
float c = fbm(vP * 18.0);
float crack = smoothstep(0.495, 0.5, c) * smoothstep(0.515, 0.505, c);
```

### 3️⃣ 铅丹变黑：一个真实的美术史细节

敦煌壁画里人物"脸是黑的"，**不是当年就那么画的**——唐代用的铅丹（红色）
氧化后变成深褐近黑。所以这个效果要满足两个条件才对：

- **只作用在红色区**（其他矿物颜料没这个问题）
- **斑块状**，不是整体压暗

```glsl
float oxid  = smoothstep(0.55, 0.85, fbm(vP * 5.2));
float isRed = step(region, 0.40);
col = mix(col, vec3(0.13, 0.10, 0.09), oxid * isRed * uAge * 0.75);
```

### 踩过的坑

- **色区别用纯 fbm 划分**，那会得到"迷彩"。真壁画是**分层分块**设色的，
  一栏一栏往上排。改用 `vP.y` 方向的色带做主结构、噪声只负责把边界啃毛。
- **前景和背景用同一套 shader 会糊成一片**。给背景墙单独一个 `uRecede`
  压暗并去饱和，图与底才分得开。
- **背景墙用平面就够了**。先用半径 2.6 的圆柱，它最前端跑到 `z=+1.4`
  把前面的浮雕全挡住；改缓弧又因为 `thetaStart` 朝向不好控变成一块悬空小板。
  壁画本来就画在平墙上，`PlaneGeometry` 没这些坑。
- **暗部下限别压太狠**。壁画是平涂，本来就没多少明暗；压到 0.62 整张图发闷，
  矿物色的饱和度全看不出来了。

## 工笔：勒线才是骨架

工笔和水墨的差别不在"画得细"，在于**它是线主导的**。所以除了把色阶从 5 阶提到 9 阶、
把墨换成矿物色之外，真正的难点是那条线怎么画。

### 描边的三条路，两条走不通

按 SKILL.md 的性能建议「能在材质里做的就在材质里做」，先试了材质内的两种做法：

| 做法 | 结果 |
|------|------|
| `fwidth(N)` 检测法线突变画结构折边 | ❌ **光滑有机形体上基本无效**。这类形体没有硬转折，`length(fwidth(N))` 全场趋近 0，调阈值救不回来 |
| rim（`1 - dot(N,V)`）压出轮廓暗边 | ❌ 能压出边，但那是**渐变带不是线**，宽度随曲率变化，没有勒线该有的等宽感 |
| **反向外壳**（沿法线外扩 + `side: BackSide`） | ✅ 真正等宽的线，且**不需要后处理** |

> 调试这类"看不见的效果"有个省事办法：把各个分量**分别输出到 r/g 通道**，
> 一眼就能看出哪个在工作、哪个是 0。我就是这么发现 `fwidth` 那条全场为零的。

反向外壳的做法：

```js
const outlineMaterial = new THREE.ShaderMaterial({
  uniforms: { uLine, uThickness: { value: 0.018 } },
  side: THREE.BackSide,              // 只渲染背面
  vertexShader: `
    uniform float uThickness;
    void main() {
      vec3 p = position + normalize(normal) * uThickness;   // 沿法线外扩
      gl_Position = projectionMatrix * modelViewMatrix * vec4(p, 1.0);
    }`,
  fragmentShader: `uniform vec3 uLine; void main(){ gl_FragColor = vec4(uLine, 1.0); }`,
});
// 每个本体挂一个外壳作为子对象，跟着一起变换
mesh.add(new THREE.Mesh(geo, outlineMaterial));
```

### 代价要说清楚

反向外壳虽然不引 EffectComposer，**但它不免费**：每个物体多渲染一遍，
demo 里实测 **draw call 3 → 6、三角面 25,248 → 50,496，正好翻倍**。

所以「材质内做」和「后处理」的取舍不是一句"别引 EffectComposer"能概括的：

- **物体少**（本 demo 3 个）：反向外壳更划算，省掉一整套后处理管线
- **物体多 / 面数高**：外壳的成本随场景线性增长，而 Sobel 后处理是**一次全屏 pass，
  与场景复杂度无关**——这时候后处理反而更便宜
- 只有 Sobel 读深度/法线缓冲才能画**内部结构线**，外壳只能画外轮廓

### 另外两个细节

- **勒线必须明显深于最重的一阶颜色**。初版把线色设得和 `deep` 很接近，
  而 rim 线恰好落在最暗处——深色画在深色上，等于没画。工笔的线本来就是墨线，直接用近黑最稳。
- **扰动要比水墨小一个量级**。水墨靠噪声打破机械感，工笔靠线立骨架，
  噪声一大线就毛了。这里用的是水墨的 1/4（0.04 vs 0.16）。

## 剪纸皮影：把水墨那套反过来

水墨方案的三步是「量化明暗 → 边缘压深 → 噪声扰动」。剪纸皮影**每一步都要反着做**，
这也是为什么它值得单独实现一遍——能验证这套思路不是只会「色调分离」一招。

### 1️⃣ 完全平面化 —— 不写 `dot(N, L)`

皮影是一张平的皮子，正面背面一个色，没有受光面和背光面。所以片元着色器里
**刻意不引入光照项**：

```glsl
// 水墨： float lam = max(dot(N, uLight), 0.0);  → 再量化
// 皮影： 直接就是纸的本色，颜色不随朝向变化
vec3 col = uPaper;
```

### 2️⃣ 背光透射 —— rim 往亮里走，不往暗里走

同样是 rim，方向完全相反：水墨用它压出浓墨，皮影用它表现灯光从薄边透出来。

```glsl
float rim   = 1.0 - max(dot(N, normalize(vV)), 0.0);
float bleed = smoothstep(0.05, 0.75, rim);
col = mix(col, uGlow, bleed * 0.95);   // 水墨这里是 mix(col, uInk, ...)
```

阈值要放低、范围要拉宽，否则只有镂空边缘发光、外轮廓是死的。

### 3️⃣ 信息量在「镂空」不在「体积」

剪纸没有明暗层次可用，全靠轮廓和镂空说话，所以几何体要用 `Shape` + `holes`
而不是现成的球/环：

- **瓣尖必须尖**：`pow(abs(cos(n*t/2)), 1.9)` 把峰压尖、谷压宽。用圆润的
  正弦花瓣会像齿轮，不像刀剪的。
- **放射长条槽比圆孔像得多**：`Path.absellipse(..., rotation)` 让长轴对准圆心。
- **多层要错开**：三层同心同大小时，前面那张会把后面全挡住，「层」根本看不出来。
  尺寸拉开 + 初始角度错开 + 轻微偏移才有叠透效果。

### 灯光与幕布

皮影的光源在幕布后面，画面是「暗幕布上一盏灯」，**不是满屏发光**。
灯晕范围和强度都要收着用，否则背景过曝会把剪影吞掉。

## 配色方案

复用中国传统色，三套起步：

| 风格 | 纸色 | 浓墨 | 中间调 |
|------|------|------|--------|
| **水墨** | `#F7F5F0` | `#2E2A26` | `#5A5550` |
| **青绿** | `#F2F5F2` | `#14322B` | `#2F6B5E` |
| **朱砂** | `#FBF7F4` | `#3A1512` | `#B23A2E` |

> 关键：**背景色要和纸色一致**，否则物体像贴在画上而不是画在纸上。

## 完整可运行示例

`demo.html` —— 水墨（Three.js r170 + importmap，单文件无需构建）：

- 实时水墨 shader（上面三个技法全在里面）
- 三套配色可切换（水墨 / 青绿 / 朱砂）
- 实测：2 draw call / 14496 三角面 / WebGL 无错误

`qinglv-demo.html` —— 青绿山水（2026-08-22 新增）：

- 色阶量化 + **石青石绿按海拔映射** + **金色描边**
- 三档可切换：千里江山 / 金碧（金更重）/ **墨线对照**
- 「墨线对照」档是这个 demo 的价值所在：**完全相同的几何体与青绿配色，
  只把线色从暖金换成墨黑**，「金碧」的贵气立刻消失。
  一个参数就能验证表里那句「edge 用暖金而非墨黑」不是随口说的

`dunhuang-demo.html` —— 敦煌壁画（2026-08-22 新增）：

- 三色限定（土红/石青/石绿）+ **多倍频噪声做剥落、龟裂、铅丹变黑**
- 三档老化可切换：盛唐 / 北魏（更重）/ **初绘（几乎不破坏）**
- 「初绘」那档是这个 demo 最有说服力的地方：**同一套 shader，只把 `uAge`
  从 1.0 降到 0.15，画面立刻变成鲜艳平涂，一点"敦煌感"都没有**

`gongbi-demo.html` —— 工笔（2026-08-20 新增）：

- 9 阶量化 + 矿物色（石青/石绿/朱砂）+ **反向外壳墨线**
- 界面上实时显示 draw call / 三角面 / fps，性能代价看得见不靠嘴说
- 实测：**描边让 draw call 和三角面正好翻倍**（3→6 call，25,248→50,496 面）

`papercut-demo.html` —— 剪纸皮影（2026-08-20 新增）：

- **和水墨正好相反的一套做法**，见下面「剪纸皮影」一节
- 三层镂空叠透 + 幕布灯晕，三套配色可切换（皮影 / 窗花 / 敦煌）
- 实测：headless Chrome 真 GPU 渲染无 WebGL 错误；1400×950 与 390×844
  两种尺寸都完整成像（相机距离按宽高比自适应，早期版本在竖屏会裁掉一圈）

在线预览：https://sanhuang520-ship-it.github.io/awesome-chinese-ai-tools/themes/ink3d.html

### 只做方案审查时

用户明确说“不修改、不运行，只做技术方案或检查现成 Demo”时，**先给结论，限制读取范围**：

1. 先用本文件已有的三项技法、性能要点和 Demo 路径回答。
2. 如需核对实现，只搜索 `demo.html` 中与问题直接相关的行；不要整文件输出，也不要读取 `intro-demo.html`，除非用户问开场动画。
3. 最多核对 3 类证据：Three.js 版本、shader 关键字、性能保护。每类只摘必要行号与结论。
4. 不启动浏览器、不运行 Demo，就明确写“静态源码审查，未做运行时验证”。

静态审查应控制在：**技术结论 → 移动端风险 → Demo 路径 → 未验证项**，不要因为仓库里已有源码就展开成逐行代码审计。

#### 用户给出字数上限时

字数上限是硬门槛，不是大致篇幅。先在内部压缩并计数字符，再只输出成品；不要把过程说明、长绝对路径或额外寒暄计入最终答案。中文短审查优先使用下面的紧凑结构：

```text
结论：静态思路可行，未运行。
技法：①…；②…；③…。
风险：①…；②…；③…。
路径：`skills/guofeng-threejs/demo.html`；`skills/guofeng-threejs/intro-demo.html`。
未实测：FPS/功耗/内存、目标机型与 WebView、降级和上下文恢复。
```

- 用户要求 300 字内时，最终响应按 Unicode 字符计数必须 `<= 300`；拿不准就压到约 220–260 字留余量。
- 路径用仓库相对路径，不泄露临时目录或用户本机路径。
- 用户禁止运行时，只做 `rg` / 定点读取等静态核对；不要启动浏览器、服务器、构建或 Demo。

## 其他中式风格（表内 4 种已全部实现）

| 风格 | 关键技法 |
|------|---------|
| **青绿山水** | ✅ **已实现**，见 `qinglv-demo.html` 与下节 |
| **敦煌壁画** | ✅ **已实现**，见 `dunhuang-demo.html` 与下节 |
| **剪纸皮影** | ✅ **已实现**，见 `papercut-demo.html` 与下节 |
| **工笔** | ✅ **已实现**，见 `gongbi-demo.html` 与下节（描边没用 Sobel，原因写在那里） |

## 性能要点

1. **shader 里的 noise 很贵**。手机端把 `noise(vP * 40.0)` 的纸纹改成贴图采样。
2. **draw call 越少越好**。同材质的物体用 `InstancedMesh`。
3. **像素比要限制**：`renderer.setPixelRatio(Math.min(devicePixelRatio, 2))`，否则高分屏直接卡死。
4. **不用后处理就别引 EffectComposer**，能在材质里做的就在材质里做。
   ⚠️ 但这条不是绝对的——**"材质里做"也可能更贵**。做工笔勒线时实测：
   反向外壳虽然免了后处理管线，却让 draw call 和三角面**正好翻倍**（3→6，25,248→50,496），
   而且成本随物体数量线性增长；Sobel 后处理是一次全屏 pass，与场景复杂度无关。
   **物体少用外壳，物体多/面数高时后处理反而便宜**，别照搬结论，按场景算一次。
   （详见「工笔：勒线才是骨架」一节）
5. 移动端建议 **60fps 掉到 30fps 就该降级**——加个 FPS 监测自动降质量。

## 边界

1. **不做通用 Three.js 教学**。基础用法请看 [官方文档](https://threejs.org/docs/)，这里只讲中式渲染。
2. **不保证跨设备一致**。GLSL 在不同 GPU/驱动上有精度差异，重要项目要真机测。
3. **不模仿具体画家风格**。可以做"水墨感"，不做"齐白石风格"——风格模仿有争议。
4. **不确定的图形学细节要说明**。涉及特定 GPU 兼容性、WebGPU 迁移等，建议查最新文档。

## 相关

- `guochao-visual-cn` —— 国潮视觉（AI 出图用）
- `chinese-web-themes` —— 中式网页主题（2D 排版用）
- 三者关系：**出图** → **排版** → **3D 呈现**
