@navaramap/three 0.0.5 → 0.0.6

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.
Files changed (58) hide show
  1. package/README.md +1 -1
  2. package/dist/assets/fontWorker-JfTCpd38.js +2 -0
  3. package/dist/assets/fontWorker-JfTCpd38.js.map +1 -0
  4. package/dist/assets/{index-TKhRxurv.js → index-B3U_DvcP.js} +2 -2
  5. package/dist/assets/index-B3U_DvcP.js.map +1 -0
  6. package/dist/assets/{navara_wasm_font_worker_bg-Kk06ArRB.wasm → navara_wasm_font_worker_bg-DympKYVX.wasm} +0 -0
  7. package/dist/assets/{navara_wasm_worker_bg-BZjLKf4i.wasm → navara_wasm_worker_bg-Bx_l45cW.wasm} +0 -0
  8. package/dist/index.d.ts +510 -374
  9. package/dist/index.js +6150 -5810
  10. package/dist/index.js.map +1 -1
  11. package/dist/index.umd.cjs +1188 -793
  12. package/dist/index.umd.cjs.map +1 -1
  13. package/package.json +12 -12
  14. package/src/core/DescRegistry.ts +3 -2
  15. package/src/core/EffectDesc.ts +10 -0
  16. package/src/core/EffectDescRegistry.ts +14 -0
  17. package/src/core/MeshDesc.ts +39 -2
  18. package/src/core/MeshDescWithSelectiveEffect.ts +14 -2
  19. package/src/core/ViewContext.ts +116 -7
  20. package/src/device.ts +4 -2
  21. package/src/index.ts +123 -11
  22. package/src/layer.ts +7 -5
  23. package/src/layers/effect/MRTPassEffectDesc.ts +34 -3
  24. package/src/layers/effect/SelectiveEffectDesc.ts +10 -2
  25. package/src/layers/effect/TransparentPassEffectDesc.ts +5 -4
  26. package/src/layersManager.ts +32 -1
  27. package/src/material/enhancer/model/modelBaseEnhancer/shader.ts +3 -2
  28. package/src/material/enhancer/polygon/polygonBaseEnhancer/index.ts +0 -6
  29. package/src/material/enhancer/polygon/polygonBaseEnhancer/mutates.ts +0 -6
  30. package/src/material/enhancer/polygon/polygonBaseEnhancer/shader.ts +4 -10
  31. package/src/material/enhancer/polygon/polygonBaseEnhancer/state.ts +1 -4
  32. package/src/material/enhancer/polygon/polygonBaseEnhancer/types.ts +0 -9
  33. package/src/material/enhancer/sdfText/sdfTextBaseEnhancer/coverage.ts +31 -0
  34. package/src/material/enhancer/sdfText/sdfTextBaseEnhancer/shader.test.ts +75 -0
  35. package/src/material/enhancer/sdfText/sdfTextBaseEnhancer/shader.ts +26 -0
  36. package/src/material/enhancer/sdfText/sdfTextBaseEnhancer/types.ts +1 -1
  37. package/src/material/gbufferLayout.test.ts +171 -0
  38. package/src/material/gbufferLayout.ts +298 -0
  39. package/src/material/index.ts +1 -0
  40. package/src/material/macro/tileShader.test.ts +35 -33
  41. package/src/material/macro/tileShader.ts +33 -10
  42. package/src/material/overrideMaterialsForMRT.ts +113 -71
  43. package/src/mesh/DrapedMesh.test.ts +57 -0
  44. package/src/mesh/DrapedMesh.ts +82 -0
  45. package/src/mesh/model.ts +3 -0
  46. package/src/mesh/polygon.ts +3 -2
  47. package/src/mesh/polyline.ts +3 -0
  48. package/src/mesh/sdfText/layout.ts +1 -3
  49. package/src/mesh/tile/index.ts +8 -4
  50. package/src/passes/CustomRenderPass.test.ts +143 -0
  51. package/src/passes/CustomRenderPass.ts +273 -30
  52. package/src/passes/RenderTargetCopyPass.ts +6 -0
  53. package/src/plugins/AttributionPlugin/index.ts +3 -2
  54. package/src/shaders/index.ts +16 -0
  55. package/src/type/index.ts +9 -51
  56. package/dist/assets/fontWorker-OwvKiQ8o.js +0 -2
  57. package/dist/assets/fontWorker-OwvKiQ8o.js.map +0 -1
  58. package/dist/assets/index-TKhRxurv.js.map +0 -1
@@ -49,6 +49,8 @@ export class MRTPassEffectDesc extends EffectDesc<
49
49
  this.view.globe,
50
50
  {
51
51
  debugNormal: !!this.config.mrt?.debugNormal,
52
+ buffers: this.view.buffers,
53
+ lit: this.view.lit,
52
54
  },
53
55
  );
54
56
 
@@ -56,15 +58,44 @@ export class MRTPassEffectDesc extends EffectDesc<
56
58
  }
57
59
 
58
60
  get normalBuffer(): Texture | undefined {
59
- return this.raw?.gbufferRenderTarget.textures[1];
61
+ const pass = this.raw;
62
+ const index = pass?.textureIndex.normal;
63
+ return index === undefined
64
+ ? undefined
65
+ : pass?.gbufferRenderTarget.textures[index];
60
66
  }
61
67
 
68
+ /**
69
+ * Selective-effect bitmask buffer (R=bitmask).
70
+ * `undefined` when the view disabled `buffers.selectiveEffect`.
71
+ */
62
72
  get effectIdsBuffer(): Texture | undefined {
63
- return this.raw?.gbufferRenderTarget.textures[2];
73
+ const index = this.raw?.textureIndex.effectIds;
74
+ return index === undefined
75
+ ? undefined
76
+ : this.raw?.gbufferRenderTarget.textures[index];
64
77
  }
65
78
 
79
+ /**
80
+ * Emissive RGB buffer (HDR).
81
+ * `undefined` when the view disabled `buffers.emissive`.
82
+ */
66
83
  get emissiveBuffer(): Texture | undefined {
67
- return this.raw?.gbufferRenderTarget.textures[3];
84
+ const index = this.raw?.textureIndex.emissive;
85
+ return index === undefined
86
+ ? undefined
87
+ : this.raw?.gbufferRenderTarget.textures[index];
88
+ }
89
+
90
+ /**
91
+ * Shadow buffer (R=shadow amount, 0=lit..1=fully shadowed).
92
+ * `undefined` unless an active effect requires the `shadow` buffer.
93
+ */
94
+ get shadowBuffer(): Texture | undefined {
95
+ const index = this.raw?.textureIndex.shadow;
96
+ return index === undefined
97
+ ? undefined
98
+ : this.raw?.gbufferRenderTarget.textures[index];
68
99
  }
69
100
 
70
101
  get depthBuffer(): Texture | undefined {
@@ -7,6 +7,7 @@ import {
7
7
  } from "../../core/EffectDesc";
8
8
  import type { ViewContext } from "../../core/ViewContext";
9
9
  import type ThreeView from "../../index";
10
+ import type { GBufferName } from "../../material/gbufferLayout";
10
11
 
11
12
  import type { MRTPassEffectDesc } from "./MRTPassEffectDesc";
12
13
 
@@ -42,6 +43,13 @@ export abstract class SelectiveEffectDesc<
42
43
  Config extends SelectiveEffectConfig = SelectiveEffectConfig,
43
44
  UpdateConfig extends SelectiveEffectUpdate = SelectiveEffectUpdate,
44
45
  > extends EffectDesc<Config, UpdateConfig> {
46
+ /**
47
+ * Buffer-based selective effects read the effectIds bitmask buffer.
48
+ * Subclasses that also read the emissive buffer (e.g. bloom) override this
49
+ * with `["selectiveEffect", "emissive"]`.
50
+ */
51
+ static requiredBuffers: readonly GBufferName[] = ["selectiveEffect"];
52
+
45
53
  protected config: Config;
46
54
 
47
55
  constructor(view: ThreeView, ctx: ViewContext, config: Config) {
@@ -94,13 +102,13 @@ export abstract class SelectiveEffectDesc<
94
102
  // Buffer accessors — used by subclass passes to read MRT data
95
103
  // ---------------------------------------------------------------------------
96
104
 
97
- /** Emissive RGB buffer (MRT attachment[3]). */
105
+ /** Emissive RGB buffer. */
98
106
  public getEmissiveBuffer(): Texture | null {
99
107
  const mrtDesc = this.find<MRTPassEffectDesc>("mrt");
100
108
  return mrtDesc?.emissiveBuffer ?? null;
101
109
  }
102
110
 
103
- /** EffectIds bitmask buffer (MRT attachment[2]). NearestFilter, discrete data. */
111
+ /** EffectIds bitmask buffer (R=bitmask). NearestFilter, discrete data. */
104
112
  public getEffectIdsBuffer(): Texture | null {
105
113
  const mrtDesc = this.find<MRTPassEffectDesc>("mrt");
106
114
  return mrtDesc?.effectIdsBuffer ?? null;
@@ -52,11 +52,12 @@ export class TransparentPassEffectDesc extends EffectDesc<
52
52
  }
53
53
 
54
54
  update(_time: number): void {
55
- // Sync lights
56
- let i = 0;
55
+ // Sync each clone from its source by id — the children order of
56
+ // scenes.light is not stable (e.g. the CSM lights are kept first for
57
+ // shadow-index ordering), so an index-based pairing would copy across
58
+ // mismatched light types.
57
59
  for (const child of this.ctx.scenes.light.children) {
58
- this.light.children[i].copy(child, true);
59
- i++;
60
+ this.lightsSyncMap.get(child.id)?.copy(child, true);
60
61
  }
61
62
  }
62
63
  }
@@ -1,4 +1,12 @@
1
- import { EffectDesc, BaseHandle, EffectHandle } from "./core";
1
+ import {
2
+ EffectDesc,
3
+ BaseHandle,
4
+ EffectHandle,
5
+ LightDesc,
6
+ LightHandle,
7
+ MeshDesc,
8
+ MeshHandle,
9
+ } from "./core";
2
10
  import { Layer, type LayerEvent } from "./layer";
3
11
 
4
12
  export class LayersManager {
@@ -60,4 +68,27 @@ export class LayersManager {
60
68
  yield handle as EffectHandle;
61
69
  }
62
70
  }
71
+
72
+ *getLightDescs(): Generator<LightHandle> {
73
+ for (const handle of this.layers.values()) {
74
+ if (!(handle instanceof BaseHandle)) continue;
75
+
76
+ const desc = handle.ref;
77
+ if (!(desc instanceof LightDesc)) continue;
78
+
79
+ yield handle as LightHandle;
80
+ }
81
+ }
82
+
83
+ *getMeshDescs(): Generator<MeshHandle> {
84
+ for (const handle of this.layers.values()) {
85
+ if (!(handle instanceof BaseHandle)) continue;
86
+
87
+ const desc = handle.ref;
88
+ // Covers instanced meshes too — InstancedMeshDesc extends MeshDesc.
89
+ if (!(desc instanceof MeshDesc)) continue;
90
+
91
+ yield handle as MeshHandle;
92
+ }
93
+ }
63
94
  }
@@ -12,6 +12,7 @@ import SpecularParsFragment from "@shaders/glsl/chunks/spucular_pars_fragment.gl
12
12
  import type { WebGLProgramParametersWithUniforms } from "three";
13
13
 
14
14
  import { createReplacer } from "../../../../utils";
15
+ import { GBUFFER_NORMAL_WRITE_PHYSICAL } from "../../../gbufferLayout";
15
16
 
16
17
  import { MODEL_BASE_SHADER_MARKERS } from "./markers";
17
18
  import type { SupportedMaterial } from "./material";
@@ -151,12 +152,12 @@ if (nvr_uPickable > 0.0 && diffuseColor.a > 0.0) {
151
152
  `,
152
153
  )
153
154
  .replace(
154
- "normalBuffer = vec4(packNormalToVec2(normal), metalnessFactor, roughnessFactor)",
155
+ GBUFFER_NORMAL_WRITE_PHYSICAL,
155
156
  `
156
157
  ${MODEL_BASE_SHADER_MARKERS.fragment.FINAL_NORMAL_START}
157
158
  vec3 finalNormal = normal;
158
159
  ${MODEL_BASE_SHADER_MARKERS.fragment.FINAL_NORMAL_END}
159
- normalBuffer = vec4(packNormalToVec2(finalNormal), metalnessFactor, roughnessFactor)
160
+ GBUFFER_WRITE_NORMAL(finalNormal, metalnessFactor, roughnessFactor)
160
161
  `,
161
162
  ).source;
162
163
  };
@@ -71,9 +71,6 @@ export function createPolygonBaseEnhancer(
71
71
  if (props.batchDataTexture) {
72
72
  mutates.setBatchDataTexture(props.batchDataTexture);
73
73
  }
74
- if (props.globeNormalTexture) {
75
- mutates.setGlobeNormalTexture(props.globeNormalTexture);
76
- }
77
74
  updateMaterialProps(material, mergedProps);
78
75
  },
79
76
 
@@ -85,9 +82,6 @@ export function createPolygonBaseEnhancer(
85
82
  if (props.batchDataTexture) {
86
83
  mutates.setBatchDataTexture(props.batchDataTexture);
87
84
  }
88
- if (props.globeNormalTexture) {
89
- mutates.setGlobeNormalTexture(props.globeNormalTexture);
90
- }
91
85
  updateMaterialProps(material, props);
92
86
  },
93
87
 
@@ -60,9 +60,6 @@ export const createBaseMutates = (useRTE: boolean): PolygonBaseMutates => {
60
60
  },
61
61
  updateUniforms: (uniforms, state) => {
62
62
  // Assign core uniform refs to shader.uniforms
63
- if (refs.uGlobeNormal) {
64
- uniforms.uGlobeNormal = refs.uGlobeNormal;
65
- }
66
63
  uniforms.nvr_uPickable = refs.nvr_uPickable;
67
64
  uniforms.uEffectIdsMask = refs.uEffectIdsMask;
68
65
  uniforms.uEmissiveIntensity = refs.uEmissiveIntensity;
@@ -100,9 +97,6 @@ export const createBaseMutates = (useRTE: boolean): PolygonBaseMutates => {
100
97
  setBatchDataTexture: (texture: UniformValue<Texture | null>): void => {
101
98
  refs.batchDataTexture = texture;
102
99
  },
103
- setGlobeNormalTexture: (texture: UniformValue<Texture | null>): void => {
104
- refs.uGlobeNormal = texture;
105
- },
106
100
  updateRteUniforms: (
107
101
  modelViewMatrixRTE: Matrix4,
108
102
  cameraPositionHigh: Vector3,
@@ -21,6 +21,7 @@ import { ShaderChunk } from "three";
21
21
  import type { WebGLProgramParametersWithUniforms } from "three";
22
22
 
23
23
  import { createReplacer } from "../../../../utils";
24
+ import { GBUFFER_NORMAL_WRITE_BASIC } from "../../../gbufferLayout";
24
25
 
25
26
  import { POLYGON_BASE_SHADER_MARKERS } from "./markers";
26
27
  import type { SupportedMaterial } from "./material";
@@ -156,7 +157,6 @@ vPosition = (modelMatrix * vec4(transformed, 1.0)).xyz;
156
157
  ${POLYGON_BASE_SHADER_MARKERS.fragment.UNIFORM_START}
157
158
  uniform vec3 diffuse;
158
159
  uniform bool uClampToGround;
159
- uniform sampler2D uGlobeNormal;
160
160
  uniform float nvr_uPickable;
161
161
  // uEffectIdsMask is declared by overrideMaterialsForMRT (#ifdef USE_SELECTIVE_EFFECT block)
162
162
  uniform bool uIsTexturized;
@@ -205,13 +205,7 @@ ${POLYGON_BASE_SHADER_MARKERS.fragment.NORMAL_START}
205
205
  vec3 origNormal = vec3(normal);
206
206
  vec3 specular;
207
207
 
208
- if(uClampToGround) {
209
- vec2 uv = gl_FragCoord.xy / vec2(textureSize(uGlobeNormal, 0));
210
- vec3 mapN = unpackVec2ToNormal(texture2D( uGlobeNormal, uv ).xy);
211
- normal = normalize( mapN );
212
- } else {
213
- #include <normal_fragment_maps>
214
- }
208
+ #include <normal_fragment_maps>
215
209
 
216
210
  ${POLYGON_BASE_SHADER_MARKERS.fragment.NORMAL_END}
217
211
  `,
@@ -242,12 +236,12 @@ if (nvr_uPickable > 0.0 && diffuseColor.a > 0.0) {
242
236
  `,
243
237
  )
244
238
  .replace(
245
- "normalBuffer = vec4(packNormalToVec2(normal), reflectivity, roughnessFactor);",
239
+ GBUFFER_NORMAL_WRITE_BASIC,
246
240
  `
247
241
  ${POLYGON_BASE_SHADER_MARKERS.fragment.FINAL_NORMAL_START}
248
242
  vec3 finalNormal = origNormal;
249
243
  ${POLYGON_BASE_SHADER_MARKERS.fragment.FINAL_NORMAL_END}
250
- normalBuffer = vec4(packNormalToVec2(finalNormal), reflectivity, roughnessFactor);
244
+ GBUFFER_WRITE_NORMAL(finalNormal, reflectivity, roughnessFactor)
251
245
  `,
252
246
  ).source;
253
247
  };
@@ -1,10 +1,7 @@
1
1
  import type { PolygonBaseProps, PolygonBaseState } from "./types";
2
2
 
3
3
  export const DEFAULT_BASE_PROPS: Required<
4
- Omit<
5
- PolygonBaseProps,
6
- "globeNormalTexture" | "batchDataTexture" | "minMaxHeight"
7
- >
4
+ Omit<PolygonBaseProps, "batchDataTexture" | "minMaxHeight">
8
5
  > = {
9
6
  color: 0,
10
7
  opacity: 1,
@@ -42,9 +42,6 @@ export type PolygonBaseProps = {
42
42
  emissiveColor?: number;
43
43
  emissiveIntensity?: number;
44
44
 
45
- // External uniforms (passed from CommonUniforms)
46
- globeNormalTexture?: UniformValue<Texture | null>;
47
-
48
45
  // Batch texture
49
46
  batchDataTexture?: UniformValue<Texture | null>;
50
47
 
@@ -102,7 +99,6 @@ export type PolygonBaseRefs = {
102
99
 
103
100
  // Optional uniforms
104
101
  batchDataTexture?: UniformValue<Texture | null>;
105
- uGlobeNormal?: UniformValue<Texture | null>;
106
102
 
107
103
  // RTE uniforms (only present if useRTE is true)
108
104
  modelViewMatrixRTE?: UniformValue<Matrix4>;
@@ -143,10 +139,5 @@ export type PolygonBaseMutates = Mutates<
143
139
  * This is needed because batchDataTexture is an external ref passed via props.
144
140
  */
145
141
  setBatchDataTexture: (texture: UniformValue<Texture | null>) => void;
146
- /**
147
- * Set the globe normal texture ref.
148
- * This is needed because globeNormalTexture is an external ref passed via props.
149
- */
150
- setGlobeNormalTexture: (texture: UniformValue<Texture | null>) => void;
151
142
  }
152
143
  >;
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Screen-size thresholds for the distance-field text shader.
3
+ *
4
+ * A single distance lookup is sufficient while a glyph has enough screen
5
+ * pixels for every stroke to own several fragments. Below that point, a CJK
6
+ * glyph can place multiple contours inside one fragment footprint, so the
7
+ * shader averages four subpixel coverage evaluations. The blend interval keeps
8
+ * animated/world-sized labels from popping as their projected size changes.
9
+ */
10
+ export const SMALL_TEXT_SUPERSAMPLE_FULL_PPEM = 24;
11
+ export const SMALL_TEXT_SUPERSAMPLE_END_PPEM = 32;
12
+
13
+ /**
14
+ * Expand the fill contour slightly at tiny display sizes. This is the
15
+ * distance-field equivalent of stem darkening: it prevents a nominally
16
+ * one-pixel stroke from becoming a low-contrast gray line after correct area
17
+ * antialiasing. The adjustment is in screen pixels per side and disappears
18
+ * before normal-size text, so layout and atlas geometry remain unchanged.
19
+ */
20
+ export const SMALL_TEXT_STEM_DARKEN_MAX_PX = 0.15;
21
+ export const SMALL_TEXT_STEM_DARKEN_FULL_PPEM = 12;
22
+ export const SMALL_TEXT_STEM_DARKEN_END_PPEM = 32;
23
+
24
+ /**
25
+ * MTSDF's RGB median preserves corners at normal sizes, but under strong
26
+ * minification its independently filtered channels can select the wrong edge.
27
+ * The alpha channel is a true SDF, so use it at small sizes and blend back to
28
+ * RGB as the glyph gains enough screen pixels.
29
+ */
30
+ export const MSDF_TRUE_SDF_END_PPEM = 16;
31
+ export const MSDF_FULL_DETAIL_PPEM = 32;
@@ -1,7 +1,17 @@
1
+ import { SDF_PX_SIZE, atlasRangePx } from "@navaramap/font";
1
2
  import sdfTextFragmentShader from "@shaders/glsl/sdfText.frag.glsl";
2
3
  import sdfTextVertexShader from "@shaders/glsl/sdfText.vert.glsl";
3
4
  import { describe, expect, it } from "vitest";
4
5
 
6
+ import {
7
+ MSDF_FULL_DETAIL_PPEM,
8
+ MSDF_TRUE_SDF_END_PPEM,
9
+ SMALL_TEXT_SUPERSAMPLE_END_PPEM,
10
+ SMALL_TEXT_SUPERSAMPLE_FULL_PPEM,
11
+ SMALL_TEXT_STEM_DARKEN_END_PPEM,
12
+ SMALL_TEXT_STEM_DARKEN_FULL_PPEM,
13
+ SMALL_TEXT_STEM_DARKEN_MAX_PX,
14
+ } from "./coverage";
5
15
  import { LABEL_ROWS, LabelRow } from "./types";
6
16
 
7
17
  import { createSdfTextBaseEnhancer, type SupportedMaterial } from ".";
@@ -46,6 +56,71 @@ describe("sdfTextBaseEnhancer shader", () => {
46
56
  expect(transform({ useMsdf: false }).defines.USE_MSDF).toBeUndefined();
47
57
  });
48
58
 
59
+ describe("small-text coverage contract", () => {
60
+ it("injects the SDF raster size and quality-specific distance range", () => {
61
+ expect(transform({ useMsdf: false }).defines).toMatchObject({
62
+ NVR_SDF_PX_SIZE: SDF_PX_SIZE,
63
+ NVR_SDF_PX_RANGE: atlasRangePx(false),
64
+ });
65
+ expect(transform({ useMsdf: true }).defines).toMatchObject({
66
+ NVR_SDF_PX_SIZE: SDF_PX_SIZE,
67
+ NVR_SDF_PX_RANGE: atlasRangePx(true),
68
+ });
69
+ });
70
+
71
+ it("injects ordered supersampling and MTSDF transition thresholds", () => {
72
+ expect(SMALL_TEXT_SUPERSAMPLE_FULL_PPEM).toBeLessThan(
73
+ SMALL_TEXT_SUPERSAMPLE_END_PPEM,
74
+ );
75
+ expect(MSDF_TRUE_SDF_END_PPEM).toBeLessThan(MSDF_FULL_DETAIL_PPEM);
76
+ expect(SMALL_TEXT_STEM_DARKEN_FULL_PPEM).toBeLessThan(
77
+ SMALL_TEXT_STEM_DARKEN_END_PPEM,
78
+ );
79
+ expect(SMALL_TEXT_STEM_DARKEN_MAX_PX).toBeGreaterThan(0);
80
+ expect(transform({}).defines).toMatchObject({
81
+ NVR_SMALL_TEXT_SS_FULL_PPEM: SMALL_TEXT_SUPERSAMPLE_FULL_PPEM,
82
+ NVR_SMALL_TEXT_SS_END_PPEM: SMALL_TEXT_SUPERSAMPLE_END_PPEM,
83
+ NVR_SMALL_TEXT_DARKEN_MAX_PX: SMALL_TEXT_STEM_DARKEN_MAX_PX,
84
+ NVR_SMALL_TEXT_DARKEN_FULL_PPEM: SMALL_TEXT_STEM_DARKEN_FULL_PPEM,
85
+ NVR_SMALL_TEXT_DARKEN_END_PPEM: SMALL_TEXT_STEM_DARKEN_END_PPEM,
86
+ NVR_MSDF_TRUE_SDF_END_PPEM: MSDF_TRUE_SDF_END_PPEM,
87
+ NVR_MSDF_FULL_DETAIL_PPEM: MSDF_FULL_DETAIL_PPEM,
88
+ });
89
+ });
90
+
91
+ it("uses UV-derived coverage and clamps supersamples to the padded glyph boundary", () => {
92
+ expect(sdfTextFragmentShader).toContain("nvr_screenPxRange()");
93
+ expect(sdfTextFragmentShader).toContain("nvr_edgeCoverage(");
94
+ expect(sdfTextFragmentShader).toContain(
95
+ "clamp(atlasUv, vAtlasUvMin, vAtlasUvMax)",
96
+ );
97
+ expect(sdfTextFragmentShader).not.toContain("vAtlasUvMin + halfTexel");
98
+ expect(sdfTextFragmentShader).not.toContain("vAtlasUvMax - halfTexel");
99
+ expect(sdfTextVertexShader).toContain(
100
+ "vAtlasUvMin = glyphUvRect.xy / atlasSize",
101
+ );
102
+ expect(sdfTextVertexShader).toContain(
103
+ "vAtlasUvMax = glyphUvRect.zw / atlasSize",
104
+ );
105
+ expect(sdfTextFragmentShader).not.toContain("fwidth(dist)");
106
+ });
107
+
108
+ it("composites translucent outlines behind partially covered fills", () => {
109
+ expect(sdfTextFragmentShader).toContain(
110
+ "float behindFill = outlineLayer * (1.0 - fillAlpha)",
111
+ );
112
+ expect(sdfTextFragmentShader).toContain(
113
+ "float alpha = fillAlpha + behindFill",
114
+ );
115
+ expect(sdfTextFragmentShader).not.toContain(
116
+ "mix(uOutlineColor, vColor, fillAlpha)",
117
+ );
118
+ expect(sdfTextFragmentShader).toMatch(
119
+ /alpha \*= opacity;\s*if \(alpha <= 0\.0\) discard;/,
120
+ );
121
+ });
122
+ });
123
+
49
124
  // The shader indexes the label data texture as `slot * LABEL_ROWS + row`.
50
125
  // If the define and the CPU-side row table disagree, every label reads
51
126
  // another label's state — hence injecting it rather than hard-coding it.
@@ -1,9 +1,20 @@
1
+ import { SDF_PX_SIZE } from "@navaramap/font";
1
2
  import sdfTextFragmentShader from "@shaders/glsl/sdfText.frag.glsl";
2
3
  import sdfTextVertexShader from "@shaders/glsl/sdfText.vert.glsl";
3
4
  import type { WebGLProgramParametersWithUniforms } from "three";
4
5
 
6
+ import {
7
+ MSDF_FULL_DETAIL_PPEM,
8
+ MSDF_TRUE_SDF_END_PPEM,
9
+ SMALL_TEXT_SUPERSAMPLE_END_PPEM,
10
+ SMALL_TEXT_SUPERSAMPLE_FULL_PPEM,
11
+ SMALL_TEXT_STEM_DARKEN_END_PPEM,
12
+ SMALL_TEXT_STEM_DARKEN_FULL_PPEM,
13
+ SMALL_TEXT_STEM_DARKEN_MAX_PX,
14
+ } from "./coverage";
5
15
  import {
6
16
  LABEL_ROWS,
17
+ sdfRadiusFor,
7
18
  type SdfTextBaseMutates,
8
19
  type SdfTextBaseState,
9
20
  } from "./types";
@@ -31,6 +42,21 @@ export const transformShader = (
31
42
  // Stride of the per-label data texture. Injected rather than hard-coded in
32
43
  // GLSL so the shader's row addressing always matches `LabelRow`.
33
44
  shader.defines.LABEL_ROWS = LABEL_ROWS;
45
+ // These values are compile-time constants for a material: quality is
46
+ // immutable, and all glyphs in an atlas use the same raster density/range.
47
+ // Injecting them from the TypeScript sources of truth prevents the shader's
48
+ // screen-pixel coverage math from drifting from the worker.
49
+ shader.defines.NVR_SDF_PX_SIZE = SDF_PX_SIZE;
50
+ shader.defines.NVR_SDF_PX_RANGE = sdfRadiusFor(state.useMsdf);
51
+ shader.defines.NVR_SMALL_TEXT_SS_FULL_PPEM = SMALL_TEXT_SUPERSAMPLE_FULL_PPEM;
52
+ shader.defines.NVR_SMALL_TEXT_SS_END_PPEM = SMALL_TEXT_SUPERSAMPLE_END_PPEM;
53
+ shader.defines.NVR_SMALL_TEXT_DARKEN_MAX_PX = SMALL_TEXT_STEM_DARKEN_MAX_PX;
54
+ shader.defines.NVR_SMALL_TEXT_DARKEN_FULL_PPEM =
55
+ SMALL_TEXT_STEM_DARKEN_FULL_PPEM;
56
+ shader.defines.NVR_SMALL_TEXT_DARKEN_END_PPEM =
57
+ SMALL_TEXT_STEM_DARKEN_END_PPEM;
58
+ shader.defines.NVR_MSDF_TRUE_SDF_END_PPEM = MSDF_TRUE_SDF_END_PPEM;
59
+ shader.defines.NVR_MSDF_FULL_DETAIL_PPEM = MSDF_FULL_DETAIL_PPEM;
34
60
 
35
61
  if (state.useRTE) {
36
62
  shader.defines.USE_RTE = 1;
@@ -7,7 +7,7 @@ import type { Mutates } from "../../MaterialEnhancer";
7
7
  /** Pixel range covered by a quality's atlas distance field; defines how
8
8
  * outline-width pixels translate to a distance-value delta. SDF (`useMsdf=false`)
9
9
  * uses the classic 35 px radius from `sdf_glyph_renderer`; MSDF uses
10
- * `MSDF_RANGE_PX` (8 px). See [`atlasRangePx`] in `@navaramap/font`. */
10
+ * `MSDF_RANGE_PX` (16 px). See [`atlasRangePx`] in `@navaramap/font`. */
11
11
  export const sdfRadiusFor = (useMsdf: boolean): number => atlasRangePx(useMsdf);
12
12
 
13
13
  /**
@@ -0,0 +1,171 @@
1
+ import {
2
+ HalfFloatType,
3
+ NearestFilter,
4
+ UnsignedByteType,
5
+ WebGLRenderTarget,
6
+ } from "three";
7
+ import { describe, expect, it } from "vitest";
8
+
9
+ import {
10
+ GBUFFER_ATTACHMENT_NAMES,
11
+ GBUFFER_DEFINE_NAMES,
12
+ GBUFFER_NORMAL_LOCATION_DEFINE,
13
+ GBUFFER_NORMAL_WRITE_BASIC,
14
+ GBUFFER_NORMAL_WRITE_PHYSICAL,
15
+ GBUFFER_EMISSIVE_LOCATION_DEFINE,
16
+ GBUFFER_PARS_FRAGMENT,
17
+ USE_GBUFFER_EMISSIVE_DEFINE,
18
+ computeGBufferDefines,
19
+ computeGBufferTextureIndex,
20
+ createGBufferAttachments,
21
+ resolveGBufferOptions,
22
+ unionGBufferRequirements,
23
+ } from "./gbufferLayout";
24
+
25
+ describe("gbufferLayout", () => {
26
+ it("keeps the GLSL chunk in sync with the TS define names", () => {
27
+ // Only strings link the two sides: a one-sided rename silently stops the
28
+ // buffer from being written, with no compile error.
29
+ for (const name of GBUFFER_DEFINE_NAMES) {
30
+ expect(GBUFFER_PARS_FRAGMENT).toContain(name);
31
+ }
32
+ // Every normal write goes through the macro, so the output can be
33
+ // compiled out wholesale — a direct assignment would not be.
34
+ expect(GBUFFER_PARS_FRAGMENT).toContain("#define GBUFFER_WRITE_NORMAL(");
35
+ expect(GBUFFER_NORMAL_WRITE_BASIC).toMatch(/^GBUFFER_WRITE_NORMAL\(/);
36
+ expect(GBUFFER_NORMAL_WRITE_PHYSICAL).toMatch(/^GBUFFER_WRITE_NORMAL\(/);
37
+ });
38
+
39
+ it("packs optional attachment indices without gaps", () => {
40
+ expect(
41
+ computeGBufferTextureIndex(
42
+ resolveGBufferOptions({
43
+ normal: true,
44
+ selectiveEffect: true,
45
+ emissive: true,
46
+ shadow: true,
47
+ }),
48
+ ),
49
+ ).toEqual({ color: 0, normal: 1, effectIds: 2, emissive: 3, shadow: 4 });
50
+ // A disabled buffer never leaves a hole: later buffers slide down. Normal
51
+ // is optional too, so shadow lands directly after color without it.
52
+ expect(
53
+ computeGBufferTextureIndex(resolveGBufferOptions({ shadow: true })),
54
+ ).toEqual({ color: 0, shadow: 1 });
55
+ // Defaults allocate nothing beyond color.
56
+ expect(computeGBufferTextureIndex(resolveGBufferOptions())).toEqual({
57
+ color: 0,
58
+ });
59
+ });
60
+
61
+ it("stamps location defines matching the computed indices", () => {
62
+ expect(
63
+ computeGBufferDefines(
64
+ resolveGBufferOptions({ normal: true, emissive: true }),
65
+ ),
66
+ ).toEqual({
67
+ [GBUFFER_NORMAL_LOCATION_DEFINE]: 1,
68
+ [USE_GBUFFER_EMISSIVE_DEFINE]: 1,
69
+ [GBUFFER_EMISSIVE_LOCATION_DEFINE]: 2,
70
+ });
71
+ expect(computeGBufferDefines(resolveGBufferOptions())).toEqual({});
72
+ });
73
+
74
+ it("derives the buffer configuration as the union of effect requirements", () => {
75
+ expect(unionGBufferRequirements([])).toEqual({
76
+ normal: false,
77
+ selectiveEffect: false,
78
+ emissive: false,
79
+ shadow: false,
80
+ globeNormal: false,
81
+ });
82
+ expect(
83
+ unionGBufferRequirements([
84
+ ["selectiveEffect"],
85
+ ["selectiveEffect", "emissive"],
86
+ ["shadow"],
87
+ ]),
88
+ ).toEqual({
89
+ normal: false,
90
+ selectiveEffect: true,
91
+ emissive: true,
92
+ shadow: true,
93
+ globeNormal: false,
94
+ });
95
+
96
+ // The globe normal is a copy of the normal attachment, so requesting it
97
+ // pulls the attachment in even when nothing asked for it directly.
98
+ expect(unionGBufferRequirements([["globeNormal"]])).toEqual({
99
+ normal: true,
100
+ selectiveEffect: false,
101
+ emissive: false,
102
+ shadow: false,
103
+ globeNormal: true,
104
+ });
105
+ });
106
+
107
+ it("allocates only enabled attachments, with their texture settings", () => {
108
+ const rt = new WebGLRenderTarget(4, 4);
109
+ const buffers = resolveGBufferOptions({
110
+ normal: true,
111
+ selectiveEffect: true,
112
+ emissive: true,
113
+ shadow: true,
114
+ });
115
+ createGBufferAttachments(rt, buffers);
116
+
117
+ expect(rt.textures.length).toBe(5);
118
+ const index = computeGBufferTextureIndex(buffers);
119
+ invariantDefined(index.effectIds);
120
+ invariantDefined(index.emissive);
121
+ const effectIds = rt.textures[index.effectIds];
122
+ expect(effectIds.type).toBe(HalfFloatType);
123
+ expect(effectIds.minFilter).toBe(NearestFilter);
124
+ expect(effectIds.magFilter).toBe(NearestFilter);
125
+ expect(rt.textures[index.emissive].type).toBe(HalfFloatType);
126
+ invariantDefined(index.shadow);
127
+ expect(rt.textures[index.shadow].type).toBe(UnsignedByteType);
128
+
129
+ // No placeholder textures for disabled buffers – indices would shift.
130
+ const defaultRt = new WebGLRenderTarget(4, 4);
131
+ createGBufferAttachments(defaultRt, resolveGBufferOptions());
132
+ expect(defaultRt.textures.length).toBe(1);
133
+ });
134
+ });
135
+
136
+ function invariantDefined(value: number | undefined): asserts value is number {
137
+ expect(value).toBeDefined();
138
+ }
139
+
140
+ describe("globeNormal", () => {
141
+ // It rides on requiredBuffers but is a separate copy target, not an MRT
142
+ // attachment, so it must not consume a slot, an index, or a location define.
143
+ const buffers = resolveGBufferOptions({ globeNormal: true, shadow: true });
144
+
145
+ it("takes no attachment index of its own", () => {
146
+ // normal is present because globeNormal implies it, not because
147
+ // globeNormal took a slot.
148
+ expect(computeGBufferTextureIndex(buffers)).toEqual({
149
+ color: 0,
150
+ normal: 1,
151
+ shadow: 2,
152
+ });
153
+ });
154
+
155
+ it("allocates no texture of its own", () => {
156
+ const rt = new WebGLRenderTarget(4, 4);
157
+ createGBufferAttachments(rt, buffers);
158
+ // color + normal (implied) + shadow — globeNormal itself adds nothing.
159
+ expect(rt.textures.length).toBe(3);
160
+ });
161
+
162
+ it("stamps no define of its own", () => {
163
+ expect(
164
+ computeGBufferDefines(unionGBufferRequirements([["globeNormal"]])),
165
+ ).toEqual({ [GBUFFER_NORMAL_LOCATION_DEFINE]: 1 });
166
+ });
167
+
168
+ it("is excluded from the attachment-backed names", () => {
169
+ expect(GBUFFER_ATTACHMENT_NAMES).not.toContain("globeNormal");
170
+ });
171
+ });