s3m-three 0.3.14 → 0.4.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -34,6 +34,8 @@ export interface ThickLineStyleUniforms {
34
34
  uWidth: { value: number };
35
35
  uOpacity: { value: number };
36
36
  uElevation?: { value: number };
37
+ /** 沿边法线外推的距离(米)。不传或 0 = 线贴在面上,与改动前一致 */
38
+ uOffset?: { value: number };
37
39
  }
38
40
 
39
41
  /** 等宽线的「单体状态」uniform:直接复用主材质 userData 里那几个对象,自动跟随 */
@@ -54,6 +56,7 @@ export interface ThickLineMaterial extends THREE.ShaderMaterial {
54
56
  ThickLineStyleUniforms &
55
57
  Required<ThickLineBatchUniforms> & {
56
58
  uTranslation: { value: THREE.Vector3 };
59
+ uOffset: { value: number };
57
60
  [uniform: string]: THREE.IUniform;
58
61
  };
59
62
  }
@@ -69,11 +72,13 @@ const VERT = /* glsl */ `
69
72
  attribute vec3 aOther;
70
73
  attribute float aSide;
71
74
  attribute float aBatchId;
75
+ attribute vec3 aNormal;
72
76
 
73
77
  uniform vec2 uResolution;
74
78
  uniform float uDpr;
75
79
  uniform float uWidth;
76
80
  uniform float uElevation;
81
+ uniform float uOffset;
77
82
  uniform vec3 uTranslation;
78
83
  uniform sampler2D uBatchTex;
79
84
  uniform sampler2D uBatchTex2;
@@ -95,8 +100,11 @@ const VERT = /* glsl */ `
95
100
  void main() {
96
101
  // 单体平移 / 旋转 / 拔高 / 隐藏:与主着色器同一份 6 行状态纹理、同一套变换口径。
97
102
  // 不采样的话,单体一动或一转棱线就留在原地——这正是「棱线跟随」要解决的。
98
- vec3 s3mA = position;
99
- vec3 s3mB = aOther;
103
+ // 外推画法:整段沿「边法线」挪出去 uOffset 米(aNormal 全 0 时等于没挪,
104
+ // 普通折边线走的还是原来那条路径)。必须在旋转 / 拔高之前做,才是物体空间里的外扩。
105
+ vec3 s3mEdgeOffset = aNormal * uOffset;
106
+ vec3 s3mA = position + s3mEdgeOffset;
107
+ vec3 s3mB = aOther + s3mEdgeOffset;
100
108
  vec3 s3mCustomOffset = vec3( 0.0 );
101
109
  mat3 s3mRot = mat3( 1.0 );
102
110
  vec3 s3mPivot = vec3( 0.0 );
@@ -257,6 +265,7 @@ export function createThickLineMaterial(
257
265
  uOpacity: style.uOpacity,
258
266
  uWidth: style.uWidth,
259
267
  uElevation: style.uElevation ?? { value: opts.elevation ?? 0 },
268
+ uOffset: style.uOffset ?? { value: 0 },
260
269
  uTranslation: { value: new THREE.Vector3(0, 0, 0) },
261
270
  uBatchTex: batch.uBatchTex,
262
271
  uBatchTex2: batch.uBatchTex2,
@@ -288,10 +297,13 @@ export function createThickLineMaterial(
288
297
  * @param batchIds 每段所属的单体 batchId(长度 = 段数)。给了它,顶点着色器才能
289
298
  * 按单体从状态纹理取平移/拔高——棱线跟随移动靠的就是这个。不传全部填 -1,
290
299
  * 着色器会跳过采样(等于不参与单体状态)。
300
+ * @param normals 每段一条「边法线」(长度 = 段数 × 6,两端点各一份,同一条棱共用)。
301
+ * 只有外推画法需要;不传就全填 0,`uOffset` 乘上去等于没推,普通折边线的行为不变。
291
302
  */
292
303
  export function buildThickLineGeometry(
293
304
  segments: ArrayLike<number>,
294
- batchIds?: ArrayLike<number>
305
+ batchIds?: ArrayLike<number>,
306
+ normals?: ArrayLike<number>
295
307
  ): THREE.BufferGeometry {
296
308
  const count = Math.floor(segments.length / 6);
297
309
  const vertexCount = count * 4;
@@ -300,6 +312,7 @@ export function buildThickLineGeometry(
300
312
  const other = new Float32Array(vertexCount * 3);
301
313
  const side = new Float32Array(vertexCount);
302
314
  const batch = new Float32Array(vertexCount);
315
+ const normal = new Float32Array(vertexCount * 3);
303
316
  const index = vertexCount > 65535 ? new Uint32Array(count * 6) : new Uint16Array(count * 6);
304
317
 
305
318
  for (let i = 0; i < count; i++) {
@@ -311,6 +324,9 @@ export function buildThickLineGeometry(
311
324
  const by = segments[s + 4];
312
325
  const bz = segments[s + 5];
313
326
  const b = batchIds ? Number(batchIds[i] ?? -1) : -1;
327
+ const nx = normals ? Number(normals[s] ?? 0) : 0;
328
+ const ny = normals ? Number(normals[s + 1] ?? 0) : 0;
329
+ const nz = normals ? Number(normals[s + 2] ?? 0) : 0;
314
330
 
315
331
  const v = i * 4;
316
332
  // A 端两枚
@@ -319,7 +335,13 @@ export function buildThickLineGeometry(
319
335
  // B 端两枚(side 反号)
320
336
  setVertex(v + 2, bx, by, bz, ax, ay, az, 1);
321
337
  setVertex(v + 3, bx, by, bz, ax, ay, az, -1);
322
- for (let k = 0; k < 4; k++) batch[v + k] = b;
338
+ for (let k = 0; k < 4; k++) {
339
+ batch[v + k] = b;
340
+ const p = (v + k) * 3;
341
+ normal[p] = nx;
342
+ normal[p + 1] = ny;
343
+ normal[p + 2] = nz;
344
+ }
323
345
 
324
346
  const o = i * 6;
325
347
  index[o] = v;
@@ -355,6 +377,7 @@ export function buildThickLineGeometry(
355
377
  geometry.setAttribute('aOther', new THREE.BufferAttribute(other, 3));
356
378
  geometry.setAttribute('aSide', new THREE.BufferAttribute(side, 1));
357
379
  geometry.setAttribute('aBatchId', new THREE.BufferAttribute(batch, 1));
380
+ geometry.setAttribute('aNormal', new THREE.BufferAttribute(normal, 3));
358
381
  geometry.setIndex(new THREE.BufferAttribute(index, 1));
359
382
  geometry.computeBoundingSphere();
360
383
  return geometry;
@@ -411,11 +434,15 @@ const QUANT = 100;
411
434
  *
412
435
  * 非索引几何没有共享顶点,直接用顶点号去重会让每条边都被判成边界边。
413
436
  * 所以这里先按位置量化把顶点归一化成 canonical id,再走同一套逻辑。
437
+ *
438
+ * @param outNormals 传了就把「边法线」按段追加进去(每段 6 个 float,两端点各一份)。
439
+ * 折边取相邻两面的平均法线,边界边取唯一那一面的法线。外推画法靠它决定往哪推。
414
440
  */
415
441
  export function buildFeatureEdgeSegments(
416
442
  geometry: THREE.BufferGeometry,
417
443
  ranges?: EdgeVertexRange[] | null,
418
- creaseAngle = 40
444
+ creaseAngle = 40,
445
+ outNormals?: number[]
419
446
  ): Float32Array | null {
420
447
  const position = geometry.getAttribute('position') as THREE.BufferAttribute | undefined;
421
448
  if (!position) return null;
@@ -480,11 +507,12 @@ export function buildFeatureEdgeSegments(
480
507
  let ny = 0;
481
508
  let nz = 0;
482
509
 
483
- const emit = (lo: number, hi: number): boolean => {
510
+ const emit = (lo: number, hi: number, ex: number, ey: number, ez: number): boolean => {
484
511
  if (out.length >= MAX_EDGE_SEGMENTS * 6) return false;
485
512
  const a = repIdx(lo);
486
513
  const b = repIdx(hi);
487
514
  out.push(px[a], py[a], pz[a], px[b], py[b], pz[b]);
515
+ outNormals?.push(ex, ey, ez, ex, ey, ez);
488
516
  return true;
489
517
  };
490
518
 
@@ -502,7 +530,21 @@ export function buildFeatureEdgeSegments(
502
530
  // 与首个共用该边的三角形比夹角
503
531
  const dot = nx * normals[first * 3] + ny * normals[first * 3 + 1] + nz * normals[first * 3 + 2];
504
532
  if (dot < cosLimit) {
505
- emit(lo, hi);
533
+ // 两面的平均法线就是这条棱「往外」的方向;两面接近反向时退回当前面法线
534
+ let ex = normals[first * 3] + nx;
535
+ let ey = normals[first * 3 + 1] + ny;
536
+ let ez = normals[first * 3 + 2] + nz;
537
+ const l = Math.hypot(ex, ey, ez);
538
+ if (l > 1e-9) {
539
+ ex /= l;
540
+ ey /= l;
541
+ ez /= l;
542
+ } else {
543
+ ex = nx;
544
+ ey = ny;
545
+ ez = nz;
546
+ }
547
+ emit(lo, hi, ex, ey, ez);
506
548
  seen.set(key, -1);
507
549
  } else {
508
550
  seen.set(key, -2);
@@ -543,7 +585,7 @@ export function buildFeatureEdgeSegments(
543
585
  if (first < 0) continue;
544
586
  const lo = Math.floor(key / span);
545
587
  const hi = key - lo * span;
546
- if (!emit(lo, hi)) break;
588
+ if (!emit(lo, hi, normals[first * 3], normals[first * 3 + 1], normals[first * 3 + 2])) break;
547
589
  }
548
590
 
549
591
  return out.length > 0 ? new Float32Array(out) : null;
@@ -0,0 +1,236 @@
1
+ /**
2
+ * GPU 描边后处理(深度 + 法线)
3
+ *
4
+ * 为什么不用「材质里求法线导数」:`dFdx` 只在 2×2 quad 内求差,quad 里落到三角形外的
5
+ * 像素是**同一个三角形的外推值**,不是隔壁三角形的真实值 —— 所以片元着色器永远看不到
6
+ * 三角形边界,而平面着色几何的折边恰好就是三角形边界,一条都描不出来(实测法线导数
7
+ * 恒为 0)。业界做描边都走「法线/深度缓冲 + 后处理」,就是这里这套。
8
+ *
9
+ * 流程(每帧):
10
+ * 1. 用 `MeshNormalMaterial` 覆盖材质把整个场景渲进一张 RT:颜色 = 视空间法线,
11
+ * 深度 = 深度纹理。这一遍没有贴图、没有光照,成本基本就是几何 + 填充。
12
+ * 2. 画一个全屏 quad:在 RT 上按 `uRadius` 采样 4 邻域,取「法线差」和「相对深度差」
13
+ * 的最大值当描边强度。采样偏移就是线宽,也就是「外扩」半径。
14
+ * 3. quad 里 `gl_FragDepth` 写回该像素在 RT 里的深度,于是它和宿主(MapLibre 的底图 /
15
+ * 地形 / 其他图层)共用同一个深度缓冲 —— 被地形挡住的地方描边也会被挡住。
16
+ *
17
+ * 只支持 WebGL2(`gl_FragDepth` + `DepthTexture` 都是核心特性)。WebGL1 上 `supported()`
18
+ * 返回 false,调用方静默跳过,不影响其它画法。
19
+ */
20
+ import * as THREE from 'three';
21
+
22
+ /** 描边参数(全部来自 style,每帧传一次) */
23
+ export interface GpuEdgeParams {
24
+ /** 描边颜色 */
25
+ color: THREE.Color;
26
+ /** 描边强度(已乘过 edgeOpacity / 整体不透明度 / 淡入系数) */
27
+ opacity: number;
28
+ /** 采样偏移(设备像素):线宽 ≈ 2×radius,也就是「外扩」半径 */
29
+ radius: number;
30
+ /** 法线差阈值:相邻采样的法线差超过它就算棱(|Δn| = 2·sin(θ/2) 量级) */
31
+ normalThreshold: number;
32
+ /** 相对深度差阈值:深度突变(几何轮廓 / 前后遮挡)也算棱 */
33
+ depthThreshold: number;
34
+ }
35
+
36
+ const QUAD_VERT = /* glsl */ `
37
+ out vec2 vUv;
38
+ void main() {
39
+ vUv = uv;
40
+ // 直接给裁剪空间坐标,不走任何矩阵 —— 全屏 quad 没有相机语义
41
+ gl_Position = vec4( position.xy, 0.0, 1.0 );
42
+ }
43
+ `;
44
+
45
+ const QUAD_FRAG = /* glsl */ `
46
+ precision highp float;
47
+
48
+ uniform sampler2D tNormal;
49
+ uniform sampler2D tDepth;
50
+ uniform vec2 uTexel;
51
+ uniform float uRadius;
52
+ uniform float uNormalThreshold;
53
+ uniform float uDepthThreshold;
54
+ uniform vec3 uColor;
55
+ uniform float uOpacity;
56
+
57
+ in vec2 vUv;
58
+ out vec4 fragColor;
59
+
60
+ /** 两个采样的法线差(法线在 RT 里编码成 0.5 + n/2) */
61
+ float normalDelta( vec2 a, vec2 b ) {
62
+ vec3 na = texture( tNormal, a ).xyz * 2.0 - 1.0;
63
+ vec3 nb = texture( tNormal, b ).xyz * 2.0 - 1.0;
64
+ return length( na - nb );
65
+ }
66
+
67
+ /**
68
+ * 两个采样的相对深度差。
69
+ * 除以较小深度是为了让「远处同样米级的落差」也有可比性:深度缓冲是非线性的,
70
+ * 远处 1 米的落差在缓冲里可能只差 1e-5,不归一化就永远达不到阈值。
71
+ */
72
+ float depthDelta( vec2 a, vec2 b ) {
73
+ float da = texture( tDepth, a ).x;
74
+ float db = texture( tDepth, b ).x;
75
+ return abs( da - db ) / max( min( da, db ), 1e-6 );
76
+ }
77
+
78
+ void main() {
79
+ float d = texture( tDepth, vUv ).x;
80
+ // 没有几何的地方(清屏深度)不画,否则整片天空都会描边
81
+ if ( d >= 0.999999 ) discard;
82
+
83
+ vec2 o = uTexel * max( uRadius, 1.0 );
84
+ vec2 dx = vec2( o.x, 0.0 );
85
+ vec2 dy = vec2( 0.0, o.y );
86
+
87
+ float n = max(
88
+ max( normalDelta( vUv, vUv + dx ), normalDelta( vUv, vUv - dx ) ),
89
+ max( normalDelta( vUv, vUv + dy ), normalDelta( vUv, vUv - dy ) )
90
+ );
91
+ float dd = max(
92
+ max( depthDelta( vUv, vUv + dx ), depthDelta( vUv, vUv - dx ) ),
93
+ max( depthDelta( vUv, vUv + dy ), depthDelta( vUv, vUv - dy ) )
94
+ );
95
+
96
+ float m = max(
97
+ smoothstep( uNormalThreshold, uNormalThreshold * 1.6, n ),
98
+ smoothstep( uDepthThreshold, uDepthThreshold * 2.0, dd )
99
+ );
100
+ float a = m * uOpacity;
101
+ if ( a <= 0.004 ) discard;
102
+
103
+ // 写回该像素在本层里的深度:与宿主共用深度缓冲,被地形挡住就不显示
104
+ gl_FragDepth = d;
105
+ fragColor = vec4( uColor, a );
106
+ }
107
+ `;
108
+
109
+ export class GpuEdgePass {
110
+ private rt: THREE.WebGLRenderTarget | null = null;
111
+ private width = 0;
112
+ private height = 0;
113
+ /** 法线通道用的覆盖材质:视空间法线编码进颜色,深度走深度纹理 */
114
+ private readonly normalMaterial = new THREE.MeshNormalMaterial();
115
+ private readonly quadScene = new THREE.Scene();
116
+ private readonly quadCamera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1);
117
+ readonly material: THREE.ShaderMaterial;
118
+
119
+ constructor() {
120
+ this.material = new THREE.ShaderMaterial({
121
+ glslVersion: THREE.GLSL3,
122
+ uniforms: {
123
+ tNormal: { value: null },
124
+ tDepth: { value: null },
125
+ uTexel: { value: new THREE.Vector2(1, 1) },
126
+ uRadius: { value: 1.5 },
127
+ uNormalThreshold: { value: 0.55 },
128
+ uDepthThreshold: { value: 0.08 },
129
+ uColor: { value: new THREE.Color('#334155') },
130
+ uOpacity: { value: 0.6 },
131
+ },
132
+ vertexShader: QUAD_VERT,
133
+ fragmentShader: QUAD_FRAG,
134
+ transparent: true,
135
+ depthTest: true,
136
+ depthWrite: false,
137
+ side: THREE.DoubleSide,
138
+ blending: THREE.NormalBlending,
139
+ });
140
+ const quad = new THREE.Mesh(new THREE.PlaneGeometry(2, 2), this.material);
141
+ quad.frustumCulled = false;
142
+ quad.raycast = () => {};
143
+ this.quadScene.add(quad);
144
+ }
145
+
146
+ /** `gl_FragDepth` 与深度纹理都是 WebGL2 核心特性,WebGL1 直接不参与 */
147
+ supported(renderer: THREE.WebGLRenderer): boolean {
148
+ return (renderer.capabilities as { isWebGL2?: boolean }).isWebGL2 === true;
149
+ }
150
+
151
+ /** 尺寸变了就重建 RT(canvas 跟着宿主缩放,必须每帧校验) */
152
+ private ensureTarget(width: number, height: number): THREE.WebGLRenderTarget {
153
+ if (this.rt && this.width === width && this.height === height) return this.rt;
154
+ this.disposeTarget();
155
+ const rt = new THREE.WebGLRenderTarget(Math.max(1, width), Math.max(1, height), {
156
+ minFilter: THREE.NearestFilter,
157
+ magFilter: THREE.NearestFilter,
158
+ format: THREE.RGBAFormat,
159
+ type: THREE.UnsignedByteType,
160
+ depthBuffer: true,
161
+ stencilBuffer: false,
162
+ generateMipmaps: false,
163
+ });
164
+ rt.texture.generateMipmaps = false;
165
+ rt.depthTexture = new THREE.DepthTexture(Math.max(1, width), Math.max(1, height), THREE.UnsignedIntType);
166
+ this.rt = rt;
167
+ this.width = width;
168
+ this.height = height;
169
+ return rt;
170
+ }
171
+
172
+ /**
173
+ * 渲一遍法线 + 深度,再把描边叠加到当前 framebuffer(调用方已画完主场景)。
174
+ *
175
+ * 注意顺序:主场景必须先画完 —— 描边 quad 要参与宿主的深度测试,
176
+ * 依赖主场景已经写好的深度。
177
+ */
178
+ render(
179
+ renderer: THREE.WebGLRenderer,
180
+ scene: THREE.Scene,
181
+ camera: THREE.Camera,
182
+ width: number,
183
+ height: number,
184
+ params: GpuEdgeParams
185
+ ): void {
186
+ if (!this.supported(renderer) || width <= 0 || height <= 0) return;
187
+ const rt = this.ensureTarget(width, height);
188
+
189
+ // ---- 1. 法线 + 深度到 RT(覆盖材质,无贴图无光照) ----
190
+ const prevOverride = scene.overrideMaterial;
191
+ const prevClear = renderer.getClearColor(new THREE.Color());
192
+ const prevClearAlpha = renderer.getClearAlpha();
193
+ scene.overrideMaterial = this.normalMaterial;
194
+ renderer.setRenderTarget(rt);
195
+ renderer.setClearColor(0x000000, 0);
196
+ renderer.clear(true, true, true);
197
+ renderer.render(scene, camera);
198
+ scene.overrideMaterial = prevOverride;
199
+ renderer.setRenderTarget(null);
200
+ renderer.setClearColor(prevClear, prevClearAlpha);
201
+
202
+ // ---- 2. 全屏描边叠加 ----
203
+ const u = this.material.uniforms;
204
+ u.tNormal.value = rt.texture;
205
+ u.tDepth.value = rt.depthTexture;
206
+ (u.uTexel.value as THREE.Vector2).set(1 / Math.max(1, width), 1 / Math.max(1, height));
207
+ u.uRadius.value = Math.max(1, params.radius);
208
+ u.uNormalThreshold.value = Math.max(0.001, params.normalThreshold);
209
+ u.uDepthThreshold.value = Math.max(0.001, params.depthThreshold);
210
+ (u.uColor.value as THREE.Color).copy(params.color);
211
+ u.uOpacity.value = params.opacity;
212
+
213
+ renderer.setViewport(0, 0, width, height);
214
+ renderer.resetState();
215
+ renderer.render(this.quadScene, this.quadCamera);
216
+ }
217
+
218
+ private disposeTarget(): void {
219
+ if (!this.rt) return;
220
+ this.rt.depthTexture?.dispose();
221
+ this.rt.dispose();
222
+ this.rt = null;
223
+ this.width = 0;
224
+ this.height = 0;
225
+ }
226
+
227
+ dispose(): void {
228
+ this.disposeTarget();
229
+ this.material.dispose();
230
+ this.normalMaterial.dispose();
231
+ this.quadScene.traverse((o) => {
232
+ const mesh = o as THREE.Mesh;
233
+ mesh.geometry?.dispose?.();
234
+ });
235
+ }
236
+ }