@driftengine/core 4.1.4 → 4.2.0

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 (47) hide show
  1. package/CHANGELOG.json +3812 -3770
  2. package/dist/index.d.ts +1 -1
  3. package/dist/index.js +7 -1
  4. package/dist/render/backend/webgl2/renderer.d.ts +15 -0
  5. package/dist/render/backend/webgl2/renderer.js +28 -7
  6. package/dist/render/backend/webgpu/pointShadowPass.d.ts +4 -0
  7. package/dist/render/backend/webgpu/pointShadowPass.js +10 -0
  8. package/dist/render/backend/webgpu/renderer.js +11 -6
  9. package/dist/render/depthConvention.d.ts +19 -0
  10. package/dist/render/depthConvention.js +21 -0
  11. package/dist/render/livePointShadowSet.js +8 -0
  12. package/dist/render/pointShadowBudget.js +59 -5
  13. package/dist/render/pointShadowImage.d.ts +40 -0
  14. package/dist/render/pointShadowImage.js +20 -0
  15. package/dist/render/pointShadowMap.d.ts +4 -0
  16. package/dist/render/pointShadowMap.js +10 -0
  17. package/dist/render/pointShadowSystem.js +9 -0
  18. package/dist/render/renderQuality.d.ts +27 -4
  19. package/dist/render/renderQuality.js +1 -1
  20. package/dist/render/shaders/flat/main.d.ts +1 -1
  21. package/dist/render/shaders/flat/main.js +13 -5
  22. package/dist/render/shaders/flat/pointShadow.d.ts +1 -1
  23. package/dist/render/shaders/flat/pointShadow.js +42 -30
  24. package/dist/render/shaders/flat/preamble.js +26 -2
  25. package/dist/render/shaders/generated/flat.wgsl.d.ts +16 -16
  26. package/dist/render/shaders/generated/flat.wgsl.js +24 -24
  27. package/dist/render/shaders/sky.d.ts +10 -6
  28. package/dist/render/shaders/sky.js +35 -2
  29. package/dist/render/textRenderer.js +23 -4
  30. package/package.json +6 -6
  31. package/src/index.ts +11 -1
  32. package/src/render/backend/webgl2/renderer.ts +28 -7
  33. package/src/render/backend/webgpu/pointShadowPass.ts +13 -0
  34. package/src/render/backend/webgpu/renderer.ts +16 -10
  35. package/src/render/depthConvention.ts +22 -0
  36. package/src/render/livePointShadowSet.ts +7 -0
  37. package/src/render/pointShadowBudget.ts +58 -5
  38. package/src/render/pointShadowImage.ts +51 -0
  39. package/src/render/pointShadowMap.ts +13 -0
  40. package/src/render/pointShadowSystem.ts +8 -0
  41. package/src/render/renderQuality.ts +28 -5
  42. package/src/render/shaders/flat/main.ts +13 -5
  43. package/src/render/shaders/flat/pointShadow.ts +42 -30
  44. package/src/render/shaders/flat/preamble.ts +26 -2
  45. package/src/render/shaders/generated/flat.wgsl.ts +24 -24
  46. package/src/render/shaders/sky.ts +37 -2
  47. package/src/render/textRenderer.ts +23 -14
@@ -1,10 +1,14 @@
1
1
  /**
2
- * Procedural gradient sky drawn as a single attribute-less fullscreen triangle.
3
- * Ray direction is reconstructed per-fragment from the inverse view-projection,
4
- * so the sky is correct under any camera. Drawn last at the far plane, so early-z wins —
5
- * and the far plane is 0 under reversed depth and 1 otherwise, which is why the value is
6
- * interpolated rather than written. A sky that says 1.0 in a reversed engine is drawn at the
7
- * *near* plane and covers the world; measured, that was 743,669 changed pixels of 921,600.
2
+ * The engine's own convention, which is what WebGPU always gets and what the WGSL is generated
3
+ * from. `scripts/wgsl.ts` reads this constant, so it stays a constant.
8
4
  */
9
5
  export declare const SKY_VERT: string;
6
+ /**
7
+ * The same shader for a context that may not have been granted reversed depth.
8
+ *
9
+ * WebGL2 compiles GLSL directly and is the only backend that can run either way, so it asks for
10
+ * the one matching what it got. WebGPU never calls this: it uses the generated WGSL, where the far
11
+ * plane is reversed by construction.
12
+ */
13
+ export declare function skyVertFor(reversed: boolean): string;
10
14
  export declare const SKY_FRAG = "#version 300 es\nprecision highp float;\n\nin vec2 vNdc;\n\nuniform mat4 uInvViewProj;\nuniform vec3 uCameraPos;\nuniform vec3 uTopColor;\nuniform vec3 uHorizonColor;\nuniform vec3 uDeepColor;\nuniform vec3 uSunDir;\nuniform vec3 uSunColor;\nuniform float uSunDiscExponent;\nuniform vec3 uMoonDir;\nuniform vec3 uMoonColor;\nuniform float uMoonAngularRadius;\nuniform float uMoonPhase;\nuniform float uNightFactor;\nuniform vec2 uCloudOffset;\nuniform vec3 uUnderwaterColor;\nuniform float uUnderwaterFactor;\n\nout vec4 outColor;\n\nfloat hash21(vec2 p) {\n return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453123);\n}\n\nfloat valueNoise(vec2 p) {\n vec2 i = floor(p);\n vec2 f = fract(p);\n vec2 u = f * f * (3.0 - 2.0 * f);\n float a = hash21(i);\n float b = hash21(i + vec2(1.0, 0.0));\n float c = hash21(i + vec2(0.0, 1.0));\n float d = hash21(i + vec2(1.0, 1.0));\n return mix(mix(a, b, u.x), mix(c, d, u.x), u.y);\n}\n\n/** Three octaves is the ceiling \u2014 drop to two before cutting anything else. */\nfloat fbm(vec2 p) {\n float v = valueNoise(p) * 0.5;\n v += valueNoise(p * 2.03) * 0.3;\n v += valueNoise(p * 4.01) * 0.2;\n return v;\n}\n\nvoid main() {\n vec4 world = uInvViewProj * vec4(vNdc, 1.0, 1.0);\n vec3 dir = normalize(world.xyz / world.w - uCameraPos);\n\n float t = dir.y;\n vec3 col = t >= 0.0\n ? mix(uHorizonColor, uTopColor, pow(min(t, 1.0), 0.55))\n : mix(uHorizonColor, uDeepColor, min(-t * 1.8, 1.0));\n\n // Celestial bodies belong to the sky, not to the void underneath. Seeing the\n // sun below the horizon made the world read as unbounded in every direction;\n // fading them out at the horizon leaves the deep reading as depth instead.\n float aboveHorizon = smoothstep(-0.04, 0.06, dir.y);\n\n /*\n * Sunset.\n *\n * Low sunlight crosses far more atmosphere than high sunlight, and that path\n * scatters the blue out of it \u2014 which is why the sun reddens as it drops and\n * why the glow around it spreads into a band rather than staying a disc. Both\n * effects peak right at the horizon and are gone within a few degrees of it,\n * so they are driven by the sun's own elevation rather than by the clock.\n *\n * TWO EDGES, BECAUSE A SUNSET ENDS. This shipped with only the upper one, so every\n * sun below the horizon read as maximum sunset and stayed there all the way to\n * straight down \u2014 and a sun direction of (0, -1, 0) is what a night world writes to\n * mean \"there is no sun in this sky\". A consumer measured the result on a black\n * sun: 87% more red in the sky than the same frame with the sun pointing up, a warm\n * band over a third of the picture in an arctic snowfield, and the world's own\n * blue-grey horizon arriving with its channel order reversed. Four night worlds were\n * not started over it, and this repository's own storm sea shipped it. The lower\n * edge is where astronomical twilight ends, near -0.31, so the term now fades out\n * over the same range the sky it models does.\n *\n * What it gives up: the glow is a function of the sun's elevation and nothing else,\n * so a caller cannot place one where its own sun is not. That is deliberate \u2014 a knob\n * here would let a world disagree with itself \u2014 but it is also what makes this wrong\n * for a body that is not our sun in our atmosphere. A world lit by something that\n * scatters differently needs its own term, not a wider edge here.\n *\n * Written ascending-and-inverted rather than as a descending smoothstep, whose\n * result GLSL ES leaves undefined when edge0 >= edge1. Every implementation seen so\n * far computes the ramp anybody would expect, which is why the descending form\n * survived this long, but the spec does not owe us that.\n */\n float sunset = (1.0 - smoothstep(-0.03, 0.30, uSunDir.y)) *\n smoothstep(-0.35, -0.05, uSunDir.y);\n vec3 ember = vec3(1.0, 0.36, 0.13);\n\n float s = max(dot(dir, uSunDir), 0.0);\n // The disc itself reddens; the last sliver above the horizon is nearly blood\n // orange, which is the moment the whole effect is remembered for.\n vec3 discColor = mix(uSunColor, uSunColor * ember, sunset * 0.85);\n col += discColor * (\n pow(s, uSunDiscExponent) * 1.2 +\n pow(s, max(uSunDiscExponent / 75.0, 1.0)) * 0.12\n ) * aboveHorizon;\n\n /*\n * The band. Brightest toward the sun's bearing and hugging the horizon,\n * falling off both with angle around the compass and with height \u2014 a sunset\n * is a direction, not a uniform warm wash, and washing the whole dome is what\n * makes a fake one look like a colour filter.\n */\n vec2 sunBearing = normalize(uSunDir.xz + vec2(1e-5));\n vec2 viewBearing = normalize(dir.xz + vec2(1e-5));\n float toward = max(dot(viewBearing, sunBearing), 0.0);\n /*\n * Measured from the horizon, and falling three times faster below it than above.\n *\n * This was abs(dir.y), which is symmetric, so the band was mirrored twenty-seven\n * degrees down into the deep \u2014 visible in any world whose ground does not cover\n * the lower half of the frame, and captured in the -Y face of every environment\n * probe, where it tinted reflections warm. Nothing in the sky mirrors a sunset\n * below the horizon; what is down there is ground, and the ground is drawn by\n * somebody else.\n *\n * Not gated by aboveHorizon like the sun disc and the moon are: that factor is\n * only 0.4 at a dir.y of zero, which is exactly where this term is brightest, so\n * multiplying by it would cut the band's core to a third and change every sunset\n * to fix a night. Falling faster below instead leaves everything at or above the\n * horizon bit-for-bit as it was, and the horizon line itself unbroken. What it\n * gives up: a camera under the world, or above a planet seen from outside its\n * atmosphere, sees the glow end sooner than the geometry does.\n */\n float fromHorizon = dir.y >= 0.0 ? dir.y : -dir.y * 3.0;\n float band = pow(1.0 - min(fromHorizon * 2.2, 1.0), 2.6);\n col += ember * sunset * band * (0.18 + 0.75 * pow(toward, 2.2));\n\n /*\n * Belt of Venus: the sky opposite a setting sun takes a cool pink-violet from\n * light already reddened on its way past. Subtle, but its absence is why an\n * artificial sunset feels like it is only happening on one side.\n */\n float away = max(-dot(viewBearing, sunBearing), 0.0);\n col += vec3(0.42, 0.28, 0.45) * sunset * band * away * 0.22;\n\n /*\n * Moon: a shaded procedural sphere-disc, not one dot subtracted from another.\n * The local sphere normal gives a curved terminator and limb; lunar phase\n * supplies the light direction around that sphere. Low-frequency noise adds\n * maria without a texture file, while a faint earthshine keeps the dark limb\n * just legible instead of cutting a mathematically black hole in the sky.\n */\n vec3 moonReference = abs(uMoonDir.y) < 0.98\n ? vec3(0.0, 1.0, 0.0)\n : vec3(1.0, 0.0, 0.0);\n vec3 moonRight = normalize(cross(moonReference, uMoonDir));\n vec3 moonUp = cross(uMoonDir, moonRight);\n float moonRadius = clamp(uMoonAngularRadius, 0.001, 0.5);\n vec2 moonUv = vec2(dot(dir, moonRight), dot(dir, moonUp)) / moonRadius;\n float moonRadiusSq = dot(moonUv, moonUv);\n float moonFacing = step(0.0, dot(dir, uMoonDir));\n float moonDisc = (1.0 - smoothstep(0.90, 1.0, moonRadiusSq)) * moonFacing;\n if (moonDisc > 0.0) {\n float sphereZ = sqrt(max(1.0 - moonRadiusSq, 0.0));\n vec3 surfaceNormal = normalize(vec3(moonUv, sphereZ));\n float phaseAngle = uMoonPhase * 6.28318530718;\n vec3 phaseLight = vec3(sin(phaseAngle), 0.0, -cos(phaseAngle));\n float phaseShade = max(dot(surfaceNormal, phaseLight), 0.0);\n float maria = valueNoise(moonUv * 3.2 + vec2(4.7, 9.1)) * 0.68 +\n valueNoise(moonUv * 9.3 - vec2(7.4, 2.6)) * 0.32;\n float albedo = mix(0.56, 1.02, maria);\n float limb = mix(0.72, 1.0, sphereZ);\n float earthshine = 0.035;\n float lunarLight = earthshine + (1.0 - earthshine) * phaseShade;\n col += uMoonColor * albedo * limb * lunarLight * moonDisc *\n 1.35 * uNightFactor * aboveHorizon;\n }\n float phaseIllumination = 0.5 - cos(uMoonPhase * 6.28318530718) * 0.5;\n float moonHalo = (1.0 - smoothstep(1.0, 3.0, length(moonUv))) * moonFacing;\n col += uMoonColor * moonHalo * 0.08 * phaseIllumination *\n uNightFactor * aboveHorizon;\n\n if (dir.y > 0.0) {\n // Stars. Stereographic projection: unlike dir.xz/dir.y it stays finite and\n // near-uniform all the way to the horizon, so stars don't smear into grain.\n // Each cell holds one jittered *point* \u2014 lighting the whole cell would draw\n // square blocks, which is what \"random pixels\" looks like.\n if (uNightFactor > 0.0) {\n vec2 p = dir.xz / (1.0 + dir.y) * 26.0;\n vec2 cell = floor(p);\n float h = hash21(cell);\n if (h > 0.93) {\n vec2 jitter = vec2(hash21(cell + 17.3), hash21(cell + 41.7));\n float d = length(fract(p) - jitter);\n float star = smoothstep(0.10, 0.0, d) * (0.4 + h * 0.6);\n // Fade out near the horizon, where haze would swallow them anyway.\n col += vec3(0.86, 0.91, 1.0) * star * uNightFactor * smoothstep(0.03, 0.3, dir.y);\n }\n }\n\n // Clouds: banded value noise on a layer above the camera, carried by the\n // world's wind \u2014 the same signal the smoke and the grass answer to, so the\n // largest moving thing in frame agrees with the smallest.\n // The floor on dir.y bounds the perspective stretch near the horizon.\n if (dir.y > 0.02) {\n vec2 uv = dir.xz / max(dir.y, 0.16) * 0.09 + uCloudOffset * 0.012;\n float band = smoothstep(0.46, 0.60, fbm(uv));\n float fade = smoothstep(0.02, 0.30, dir.y);\n vec3 cloudCol = mix(uHorizonColor, vec3(1.0), 0.45) * mix(1.0, 0.32, uNightFactor);\n col = mix(col, cloudCol, band * fade * 0.8);\n }\n }\n\n // There is no outdoor sky inside a water column. Retain a little vertical\n // gradient so the surface remains legible above and depth reads darker\n // below, while celestial bodies and clouds disappear with the same smooth\n // surface transition used by the geometry passes.\n float waterLight = mix(0.62, 1.08, clamp(dir.y * 0.5 + 0.5, 0.0, 1.0));\n col = mix(col, uUnderwaterColor * waterLight, uUnderwaterFactor);\n\n outColor = vec4(col, 1.0);\n}\n";
@@ -7,7 +7,24 @@ import { glslFarDepth } from '../depthConvention.js';
7
7
  * interpolated rather than written. A sky that says 1.0 in a reversed engine is drawn at the
8
8
  * *near* plane and covers the world; measured, that was 743,669 changed pixels of 921,600.
9
9
  */
10
- export const SKY_VERT = `#version 300 es
10
+ /**
11
+ * The source, as a function of which way depth actually runs.
12
+ *
13
+ * **Because on WebGL2 that is a runtime fact and this file is imported once.** Reversed depth there
14
+ * needs `EXT_clip_control`; a context that is not granted it runs conventional depth, and the
15
+ * renderer has a `reversedDepth` field saying so precisely because the two can disagree. This
16
+ * template used to bake `glslFarDepth()` at import, which reads the compile-time `REVERSED_DEPTH` —
17
+ * so on such a context the sky was written at `0.0` while the depth test ran conventionally, where
18
+ * `0.0` is the middle of the range rather than the far plane. The sky then beat everything in the
19
+ * far half of the scene.
20
+ *
21
+ * **Reported from Firefox on Linux, which exposes no `EXT_clip_control`**: the voxel sandbox drew
22
+ * only sky, the gilded chamber a single orange frame, and a consumer's world one flat colour with
23
+ * its interface still on top. Three symptoms, one cause, and the docstring above already named the
24
+ * mechanism in the other direction.
25
+ */
26
+ function skySource(farDepth) {
27
+ return `#version 300 es
11
28
  const vec2 POS[3] = vec2[3](vec2(-1., -1.), vec2(3., -1.), vec2(-1., 3.));
12
29
 
13
30
  out vec2 vNdc;
@@ -15,9 +32,25 @@ out vec2 vNdc;
15
32
  void main() {
16
33
  vNdc = POS[gl_VertexID];
17
34
  /* The far plane, whichever way depth runs: see glslFarDepth. */
18
- gl_Position = vec4(vNdc, ${glslFarDepth()}, 1.0);
35
+ gl_Position = vec4(vNdc, ${farDepth}, 1.0);
19
36
  }
20
37
  `;
38
+ }
39
+ /**
40
+ * The engine's own convention, which is what WebGPU always gets and what the WGSL is generated
41
+ * from. `scripts/wgsl.ts` reads this constant, so it stays a constant.
42
+ */
43
+ export const SKY_VERT = skySource(glslFarDepth());
44
+ /**
45
+ * The same shader for a context that may not have been granted reversed depth.
46
+ *
47
+ * WebGL2 compiles GLSL directly and is the only backend that can run either way, so it asks for
48
+ * the one matching what it got. WebGPU never calls this: it uses the generated WGSL, where the far
49
+ * plane is reversed by construction.
50
+ */
51
+ export function skyVertFor(reversed) {
52
+ return reversed ? SKY_VERT : skySource('1.0');
53
+ }
21
54
  export const SKY_FRAG = `#version 300 es
22
55
  precision highp float;
23
56
 
@@ -1,4 +1,4 @@
1
- import { MAX_CELLS, TEXT_CUBE, TextLayout, deviceSnappedCellSize, deviceSnappedOrigin, } from './textLayout.js';
1
+ import { MAX_CELLS, TEXT_CUBE, TextLayout, deviceSnappedOrigin } from './textLayout.js';
2
2
  import { compileProgram, uniformLocations } from './shader.js';
3
3
  import { TEXT_FRAG, TEXT_VERT } from './shaders/text.js';
4
4
  /**
@@ -103,9 +103,28 @@ export class TextRenderer {
103
103
  /* Snapped with the cell below: a whole pitch on a fractional origin still splits a stroke.
104
104
  See `deviceSnappedOrigin`. */
105
105
  setVec2(gl, this.uniforms['uOrigin'], deviceSnappedOrigin(originX, viewportWidth, gl.drawingBufferWidth), deviceSnappedOrigin(originY, viewportHeight, gl.drawingBufferHeight));
106
- /* Whole device pixels per cell, or a 5x7 face draws strokes of two different widths. The
107
- decision is shared with WebGPU; only this binding is per-backend. See `textLayout.ts`. */
108
- setFloat(gl, this.uniforms['uCellSize'], deviceSnappedCellSize(style.cellSize, viewportWidth, gl.drawingBufferWidth));
106
+ /*
107
+ * **The cell the caller asked for, drawn faithfully.**
108
+ *
109
+ * 4.1.4 snapped this down to whole device pixels here, so a 5x7 face could not draw strokes
110
+ * of two different widths. That defect is real and the snap is the right cure for it, but
111
+ * imposing it was wrong: `textWidthPx` is the only measurement a consumer has, it takes no
112
+ * viewport and so cannot know the device ratio, and every consumer went on laying out against
113
+ * the cell it asked for while this drew a smaller one. A centred line, a right-aligned column
114
+ * and a line fitted to a box all broke at once, silently, and only where the snap was not a
115
+ * no-op: a whole cell at a whole ratio is untouched, which is why it looked right in a
116
+ * full-size landscape browser and wrong in mobile portrait, where a consumer divides an
117
+ * available width by a cell count and gets a fraction.
118
+ *
119
+ * So the snap is the caller's to ask for. `deviceSnappedCellSize` is exported; a caller that
120
+ * wants whole-pixel strokes snaps once and uses that one number to measure, to lay out and to
121
+ * draw, where the measurement and the picture agree by construction.
122
+ *
123
+ * **What this gives up** is that a fractional cell draws strokes of two widths again for a
124
+ * caller that does not opt in. **What would make it wrong** is a measurement that could snap
125
+ * on the caller's behalf, which would need the viewport `textWidthPx` is not handed.
126
+ */
127
+ setFloat(gl, this.uniforms['uCellSize'], style.cellSize);
109
128
  // Far enough that a whole line barely converges, near enough that a rotating character
110
129
  // reads as turning rather than shearing.
111
130
  setFloat(gl, this.uniforms['uDepth'], Math.max(viewportWidth, 600) * 1.4);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@driftengine/core",
3
- "version": "4.1.4",
3
+ "version": "4.2.0",
4
4
  "description": "DriftEngine reusable browser 3D runtime",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -29,8 +29,8 @@
29
29
  ],
30
30
  "sideEffects": false,
31
31
  "dependencies": {
32
- "@driftengine/drft": "4.1.4",
33
- "@driftengine/physics": "4.1.4",
32
+ "@driftengine/drft": "4.2.0",
33
+ "@driftengine/physics": "4.2.0",
34
34
  "gl-matrix": "^3.4.3"
35
35
  },
36
36
  "author": "Drift Technologies",
@@ -63,8 +63,8 @@
63
63
  "prepack": "node ../../scripts/build.mjs @driftengine/core"
64
64
  },
65
65
  "devDependencies": {
66
- "@driftengine/nav": "4.1.4",
67
- "@driftengine/network": "4.1.4",
68
- "@driftengine/texture": "4.1.4"
66
+ "@driftengine/nav": "4.2.0",
67
+ "@driftengine/network": "4.2.0",
68
+ "@driftengine/texture": "4.2.0"
69
69
  }
70
70
  }
package/src/index.ts CHANGED
@@ -933,7 +933,17 @@ export { FlockRenderer } from './render/flockRenderer.ts';
933
933
  * the same shape scatter, plumes, particles, flocks and bolts already have. The measuring
934
934
  * helpers were statics on the class and are free functions now, because neither needs a device.
935
935
  */
936
- export { DEFAULT_TEXT_STYLE, textHeightPx, textWidthPx } from './render/textLayout.ts';
936
+ export {
937
+ DEFAULT_TEXT_STYLE,
938
+ /*
939
+ * Exported so a caller can ask for whole-pixel strokes, which the engine used to impose. See
940
+ * the note at the cell upload in `render/textRenderer.ts`: snap once, then measure, lay out
941
+ * and draw with that one number.
942
+ */
943
+ deviceSnappedCellSize,
944
+ textHeightPx,
945
+ textWidthPx,
946
+ } from './render/textLayout.ts';
937
947
  export type { TextStyle } from './render/textLayout.ts';
938
948
  export {
939
949
  GLYPH_HEIGHT,
@@ -2,10 +2,10 @@ import { FrameBudget } from '../budget.ts';
2
2
  import { MaterialChanges, ownsMaterial } from '../materialChanges.ts';
3
3
  import { mat4 } from 'gl-matrix';
4
4
  import {
5
- DEPTH_CLEAR,
6
5
  DEPTH_OFFSET_SIGN,
7
6
  GL_DEPTH_REMAP,
8
7
  MAX_DEPTH_LAYER,
8
+ depthClearFor,
9
9
  depthOffsetForLayer,
10
10
  GL_SHADOW_REMAP,
11
11
  REVERSED_DEPTH,
@@ -133,7 +133,7 @@ import {
133
133
  import type { PhotometricProfile } from '../../iesProfile.ts';
134
134
  import { equirectToCubeFaces } from '../../equirectToCube.ts';
135
135
 
136
- import { SKY_FRAG, SKY_VERT } from '../../shaders/sky.ts';
136
+ import { SKY_FRAG, skyVertFor } from '../../shaders/sky.ts';
137
137
  import {
138
138
  DEPTH_FRAG,
139
139
  DEPTH_INSTANCED_VERT,
@@ -1370,6 +1370,23 @@ export class WebGL2Renderer implements RendererApi {
1370
1370
  * near plane needs the granted answer and not the wished-for one.
1371
1371
  */
1372
1372
  reversedDepth = false;
1373
+ /**
1374
+ * What to clear depth to, for the convention this context was actually granted.
1375
+ *
1376
+ * **`DEPTH_CLEAR` is compile-time and this must not be.** That constant is `REVERSED_DEPTH ? 0 :
1377
+ * 1`, and `REVERSED_DEPTH` is what the engine wants rather than what a context gave — so on a
1378
+ * context without `EXT_clip_control` the buffer was cleared to 0 while `depthFunc` was correctly
1379
+ * set to `LEQUAL`, and **nothing in the scene could ever pass**: every fragment is at depth >= 0,
1380
+ * and only depth <= 0 passes. The frame that reaches the screen is the colour clear.
1381
+ *
1382
+ * **Reported from Firefox on Linux, which exposes no `EXT_clip_control`**, as every consumer
1383
+ * drawing one flat colour with its interface still on top, at a healthy 60 fps. The compare
1384
+ * beside it was already runtime; this was the half that was not, and the asymmetry is what made
1385
+ * it total rather than subtle.
1386
+ */
1387
+ private get depthClear(): number {
1388
+ return depthClearFor(this.reversedDepth);
1389
+ }
1373
1390
  /** `GL_DEPTH_REMAP * camera.viewProjection`, rebuilt once a frame. */
1374
1391
  private readonly sceneViewProj = new Float32Array(16);
1375
1392
  /**
@@ -2520,7 +2537,10 @@ export class WebGL2Renderer implements RendererApi {
2520
2537
  }
2521
2538
  }
2522
2539
  this.filmUniforms = uniformLocations(gl, this.filmProgram, 'film');
2523
- this.skyProgram = compileProgram(gl, SKY_VERT, SKY_FRAG, 'sky');
2540
+ /* The variant matching what this context was granted, not what the engine wants. See
2541
+ `skyVertFor`: a sky written at the reversed far plane on a conventional context sits in the
2542
+ middle of the depth range and paints over everything behind it. */
2543
+ this.skyProgram = compileProgram(gl, skyVertFor(this.reversedDepth), SKY_FRAG, 'sky');
2524
2544
  this.skyUniforms = uniformLocations(gl, this.skyProgram, 'sky');
2525
2545
  this.scatterProgram = compileProgram(gl, SCATTER_VERT, SCATTER_FRAG, 'scatter');
2526
2546
  this.scatterUniforms = uniformLocations(gl, this.scatterProgram, 'scatter');
@@ -4134,12 +4154,13 @@ export class WebGL2Renderer implements RendererApi {
4134
4154
  gl.uniform1i(uniforms[POINT_SHADOW_SAMPLER] ?? null, firstUnit);
4135
4155
 
4136
4156
  gl.uniform1iv(uniforms['uPointShadowLayer[0]'] ?? null, resolved.layers);
4137
- gl.uniform1fv(uniforms['uPointShadowFar[0]'] ?? null, resolved.far);
4157
+ /* Origin and far plane in one row; see `uPointShadowProjection` in the preamble. */
4158
+ gl.uniform4fv(uniforms['uPointShadowProjection[0]'] ?? null, resolved.projections);
4138
4159
  gl.uniform1fv(uniforms['uPointShadowNear[0]'] ?? null, resolved.near);
4139
4160
  gl.uniform1fv(uniforms['uPointShadowSize[0]'] ?? null, resolved.sourceRadius);
4140
4161
  gl.uniform1fv(uniforms['uPointShadowWeight[0]'] ?? null, resolved.presence);
4141
4162
  gl.uniform1iv(uniforms['uLivePointShadowLayer[0]'] ?? null, resolved.liveLayers);
4142
- gl.uniform1fv(uniforms['uLivePointShadowFar[0]'] ?? null, resolved.liveFar);
4163
+ gl.uniform4fv(uniforms['uLivePointShadowProjection[0]'] ?? null, resolved.liveProjections);
4143
4164
  gl.uniform1fv(uniforms['uLivePointShadowNear[0]'] ?? null, resolved.liveNear);
4144
4165
  gl.uniform1fv(uniforms['uLivePointShadowSize[0]'] ?? null, resolved.liveSourceRadius);
4145
4166
  gl.uniform1fv(uniforms['uLivePointShadowWeight[0]'] ?? null, resolved.liveWeights);
@@ -4669,7 +4690,7 @@ export class WebGL2Renderer implements RendererApi {
4669
4690
  shadowMap.end(this.gl);
4670
4691
  /* Back to the frame's sense; see `beginShadowPass` for why it left it. */
4671
4692
  this.gl.depthFunc(glDepthFuncEqual(this.gl));
4672
- this.gl.clearDepth(DEPTH_CLEAR);
4693
+ this.gl.clearDepth(this.depthClear);
4673
4694
  this.gpuTimer.end();
4674
4695
  this.shadowPassActive = false;
4675
4696
  this.activeShadowMap = null;
@@ -5117,7 +5138,7 @@ export class WebGL2Renderer implements RendererApi {
5117
5138
  } finally {
5118
5139
  /* Back to the frame's sense, which `beginShadowPass` leaves the same way. */
5119
5140
  gl.depthFunc(glDepthFuncEqual(gl));
5120
- gl.clearDepth(DEPTH_CLEAR);
5141
+ gl.clearDepth(this.depthClear);
5121
5142
  this.pointShadowCasters = null;
5122
5143
  this.depthPassCullsFaces = true;
5123
5144
  }
@@ -289,6 +289,19 @@ export class GpuPointShadowMap implements PointShadowSource {
289
289
  return this.image.sourceRadius;
290
290
  }
291
291
 
292
+ /** Where the image was rendered from, not where the light is now. See the image. */
293
+ get originX(): number {
294
+ return this.image.originX;
295
+ }
296
+
297
+ get originY(): number {
298
+ return this.image.originY;
299
+ }
300
+
301
+ get originZ(): number {
302
+ return this.image.originZ;
303
+ }
304
+
292
305
  get hasBaked(): boolean {
293
306
  return this.image.hasBaked;
294
307
  }
@@ -150,7 +150,7 @@ import {
150
150
  type GpuTextCube,
151
151
  } from './textPass.ts';
152
152
  import type { TextStyle } from '../../textLayout.ts';
153
- import { deviceSnappedCellSize, deviceSnappedOrigin } from '../../textLayout.ts';
153
+ import { deviceSnappedOrigin } from '../../textLayout.ts';
154
154
  import { GpuSurfaceTexture } from './surfaceTexturePass.ts';
155
155
  import {
156
156
  createSdfTextBindGroup,
@@ -3185,13 +3185,9 @@ export class WebGPURenderer implements RendererApi {
3185
3185
  deviceSnappedOrigin(originX, viewportWidth, this.surface.canvas.width),
3186
3186
  deviceSnappedOrigin(originY, viewportHeight, this.surface.canvas.height),
3187
3187
  ]);
3188
- /* Whole device pixels per cell, or a 5x7 face draws strokes of two different widths. The
3189
- decision is shared with WebGL2; only this binding is per-backend. See `textLayout.ts`. */
3190
- v.writeFloat(
3191
- vertexSlot,
3192
- at('uCellSize'),
3193
- deviceSnappedCellSize(style.cellSize, viewportWidth, this.surface.canvas.width),
3194
- );
3188
+ /* The cell the caller asked for, drawn faithfully. See `textRenderer.ts` for why the snap
3189
+ this used to impose is the caller's to ask for. */
3190
+ v.writeFloat(vertexSlot, at('uCellSize'), style.cellSize);
3195
3191
  /* Far enough that a whole line barely converges, near enough that a turn reads as a turn. */
3196
3192
  v.writeFloat(vertexSlot, at('uDepth'), Math.max(viewportWidth, 600) * 1.4);
3197
3193
  v.writeFloat(vertexSlot, at('uReveal'), style.reveal);
@@ -7720,7 +7716,12 @@ export class WebGPURenderer implements RendererApi {
7720
7716
  this.clearShadowSelection(f, i, 'uLivePointShadowLayer', 'uLivePointShadowWeight');
7721
7717
  } else {
7722
7718
  this.writeShadowSelection(f, i, 'uPointShadow', resolved.layers, resolved.presence);
7723
- this.writeShadowParams(f, 'uPointShadowFar', resolved.far);
7719
+ /*
7720
+ * A `vec4` row carrying the bake origin and the far plane, so `scatterInto` rather than
7721
+ * `writeShadowParams` — it reads the stride out of the generated layout instead of
7722
+ * counting rows, which is what AGENTS.md's 2026-09-20 alignment rule asks for.
7723
+ */
7724
+ scatterInto(f, this.fragment.fields['uPointShadowProjection'], resolved.projections, 4);
7724
7725
  this.writeShadowParams(f, 'uPointShadowNear', resolved.near);
7725
7726
  this.writeShadowParams(f, 'uPointShadowSize', resolved.sourceRadius);
7726
7727
  this.writeShadowSelection(
@@ -7730,7 +7731,12 @@ export class WebGPURenderer implements RendererApi {
7730
7731
  resolved.liveLayers,
7731
7732
  resolved.liveWeights,
7732
7733
  );
7733
- this.writeShadowParams(f, 'uLivePointShadowFar', resolved.liveFar);
7734
+ scatterInto(
7735
+ f,
7736
+ this.fragment.fields['uLivePointShadowProjection'],
7737
+ resolved.liveProjections,
7738
+ 4,
7739
+ );
7734
7740
  this.writeShadowParams(f, 'uLivePointShadowNear', resolved.liveNear);
7735
7741
  this.writeShadowParams(f, 'uLivePointShadowSize', resolved.liveSourceRadius);
7736
7742
  }
@@ -104,6 +104,28 @@ export const REVERSED_DEPTH = true;
104
104
  /** What a scene depth attachment is cleared to: the far plane. */
105
105
  export const DEPTH_CLEAR = REVERSED_DEPTH ? 0 : 1;
106
106
 
107
+ /**
108
+ * The same value for a backend that learned its convention at runtime rather than at compile time.
109
+ *
110
+ * **`DEPTH_CLEAR` above says what the engine wants; this says what a context got.** Reversed depth
111
+ * on WebGL2 needs `EXT_clip_control`, and a context not granted it runs conventional depth while
112
+ * the constant still reads 0. Clearing to 0 with a `LEQUAL` compare rejects every fragment in the
113
+ * scene — every one sits at depth >= 0 and only depth <= 0 is admitted — so the frame that reaches
114
+ * the screen is the colour clear and nothing else.
115
+ *
116
+ * Reported from Firefox on Linux, which exposes no `EXT_clip_control`: every consumer drew one flat
117
+ * colour with its interface still on top, at a healthy sixty frames a second. The depth *compare*
118
+ * beside it was already chosen at runtime, and that asymmetry is what made this total rather than
119
+ * subtle — two halves of one convention would still have drawn a picture.
120
+ *
121
+ * **Here rather than in the backend** for the 2026-08-13 rule's first clause: the decision is
122
+ * backend-neutral and only the binding is per-backend. It is also what lets a test hold the clear
123
+ * and the compare to each other, which a private field on a renderer could not.
124
+ */
125
+ export function depthClearFor(reversed: boolean): number {
126
+ return reversed ? 0 : 1;
127
+ }
128
+
107
129
  /** The WebGPU compare for "nearer than what is there". */
108
130
  export const DEPTH_COMPARE: GPUCompareFunction = REVERSED_DEPTH ? 'greater' : 'less';
109
131
 
@@ -182,6 +182,8 @@ export class LivePointShadowSet<M extends PointShadowSource> {
182
182
  out.liveFar.fill(1);
183
183
  out.liveNear.fill(0.01);
184
184
  out.liveSourceRadius.fill(0);
185
+ out.liveProjections.fill(0);
186
+ for (let k = 0; k < out.liveLayers.length; k++) out.liveProjections[k * 4 + 3] = 1;
185
187
  /*
186
188
  * Zero, then written under the owning light below. The weights used to be a straight copy of
187
189
  * this set's own two, because the arrays were addressed by live slot; they are addressed by
@@ -214,6 +216,11 @@ export class LivePointShadowSet<M extends PointShadowSource> {
214
216
  out.liveFar[shaderSlot] = map.far;
215
217
  out.liveNear[shaderSlot] = map.near;
216
218
  out.liveSourceRadius[shaderSlot] = map.sourceRadius;
219
+ /* Where the image was rendered from, not where the light is now. See `projections`. */
220
+ out.liveProjections[shaderSlot * 4] = map.originX;
221
+ out.liveProjections[shaderSlot * 4 + 1] = map.originY;
222
+ out.liveProjections[shaderSlot * 4 + 2] = map.originZ;
223
+ out.liveProjections[shaderSlot * 4 + 3] = map.far;
217
224
  /*
218
225
  * The handover weight *and* the image's own arrival, multiplied.
219
226
  *
@@ -244,7 +244,24 @@ export function runPointShadowBakes<M extends StaleCheck, L extends BakeableLigh
244
244
  scratch.staleOwner[slot] = lightIndex;
245
245
  scratch.staleRun[slot] = 0;
246
246
  }
247
- const run = stale ? Math.min((scratch.staleRun[slot] ?? 0) + 1, CHRONIC_STALE_FRAMES) : 0;
247
+ /*
248
+ * **The run decays rather than resetting, and it climbs past the threshold before it stops.**
249
+ *
250
+ * A light that wanders on a curve is not stale on *every* frame — near the turning points of
251
+ * its travel it moves less in a frame than the rebake tolerance, so a run that reset to zero
252
+ * lost the tracking allowance there and the map dropped back to two faces a frame. The shadow
253
+ * then travelled smoothly through the fast part of the wander and stepped through the slow
254
+ * part, which is not motion and not stillness: it reads as a vibration. Reported that way,
255
+ * against a brazier, once the tracking allowance was in.
256
+ *
257
+ * So the run is hysteresis. It climbs to twice the threshold, which buys a light that has
258
+ * earned the allowance three quiet frames before it loses it, and decays one frame at a time
259
+ * so a light that genuinely settles stops re-baking rather than holding a budget for ever.
260
+ */
261
+ const previous = scratch.staleRun[slot] ?? 0;
262
+ const run = stale
263
+ ? Math.min(previous + 1, CHRONIC_STALE_FRAMES * 2)
264
+ : Math.max(previous - 1, 0);
248
265
  scratch.staleRun[slot] = run;
249
266
  scratch.chronic[slot] = run >= CHRONIC_STALE_FRAMES ? 1 : 0;
250
267
  }
@@ -259,6 +276,38 @@ export function runPointShadowBakes<M extends StaleCheck, L extends BakeableLigh
259
276
 
260
277
  let faces = facesPerFrame;
261
278
  let urgentSpent = false;
279
+ /*
280
+ * **One wandering light finishes its cube in the frame it started it**, the same allowance a
281
+ * light with no image at all gets, and for a reason that turns out to be the same one: a map
282
+ * dribbled out at two faces a frame is a map nobody can sample correctly until it lands.
283
+ *
284
+ * A cold light cannot be sampled *at all* until its six faces are in, so it takes the whole
285
+ * cube at once. A chronically stale one can be sampled throughout and is wrong in a quieter
286
+ * way: `planBake` pins the origin for the whole of a resumed bake, so six faces spread over
287
+ * three frames means the published origin steps once every third frame. The shader shoots from
288
+ * that origin, so the shadow it draws steps with it — reported on a brazier as motion that was
289
+ * "really fast, not smooth, snappy, and unrealistic", which is a 20 Hz sample of a flame that
290
+ * wanders about once a second.
291
+ *
292
+ * Given the cube in one frame the origin moves every frame and the shadow travels instead.
293
+ *
294
+ * **Bounded to one light, and it has to be.** Eight braziers each taking six passes is the 48
295
+ * passes and 89 ms `pointShadowFacesPerFrame` exists to prevent. One is the light being shaded
296
+ * that most recently went stale, which is the one somebody is standing next to; the rest keep
297
+ * dribbling under the ordinary budget, and their shadows step rather than travel. That is the
298
+ * right way round: a shadow you are looking at moves, and a shadow across the square does not
299
+ * cost the frame.
300
+ *
301
+ * It is spent *after* the cold and settling groups, because `planPointShadowBakes` orders the
302
+ * chronic ones last — so a light arriving in range still gets its first image ahead of a flame
303
+ * refining one it already has.
304
+ *
305
+ * **What would make it wrong** is a caster set heavy enough that six passes over it does not
306
+ * fit the frame. Measured against a game whose whole static world is about 8,500 triangles in
307
+ * a handful of draws; a world an order of magnitude heavier wants `pointShadowFacesPerFrame`
308
+ * raised or this given up, and `gpuTiming` is how to tell which.
309
+ */
310
+ let trackingSpent = false;
262
311
  for (let i = 0; i < planned && faces > 0; i++) {
263
312
  const slot = scratch.order[i] ?? -1;
264
313
  /*
@@ -270,10 +319,14 @@ export function runPointShadowBakes<M extends StaleCheck, L extends BakeableLigh
270
319
  const map = lightIndex >= 0 ? pool.mapForLight(lightIndex) : undefined;
271
320
  if (light === undefined || map === undefined) continue;
272
321
 
273
- const cold = slot < pool.sampledCount && scratch.ready[slot] === 0;
274
- const budget = cold && !urgentSpent ? faceCount : faces;
275
- if (cold && !urgentSpent) urgentSpent = true;
276
- faces -= bake(map, light, staticCasters, budget);
322
+ const sampled = slot < pool.sampledCount;
323
+ const cold = sampled && scratch.ready[slot] === 0;
324
+ const urgent = cold && !urgentSpent;
325
+ /* A light being shaded whose map will be stale again next frame however it is served. */
326
+ const tracking = !cold && sampled && scratch.chronic[slot] === 1 && !trackingSpent;
327
+ if (urgent) urgentSpent = true;
328
+ if (tracking) trackingSpent = true;
329
+ faces -= bake(map, light, staticCasters, urgent || tracking ? faceCount : faces);
277
330
  }
278
331
 
279
332
  /*
@@ -202,6 +202,27 @@ export class PointShadowImage {
202
202
  private pendingNear = 0;
203
203
  private pendingSourceRadius = 0;
204
204
 
205
+ /**
206
+ * Where this image was rendered from, which is what a sampler has to shoot its ray from.
207
+ *
208
+ * **Not the light's position**, and the difference is the whole point: `matchesSource` lets a
209
+ * light drift up to the caller's rebake tolerance from here before the image is re-rendered,
210
+ * so for any light that wanders these two are apart on nearly every frame. A shader that
211
+ * builds its direction and its distance from the live light asks this picture a question the
212
+ * picture cannot answer. See `ResolvedPointShadows.origins`.
213
+ */
214
+ get originX(): number {
215
+ return this.x;
216
+ }
217
+
218
+ get originY(): number {
219
+ return this.y;
220
+ }
221
+
222
+ get originZ(): number {
223
+ return this.z;
224
+ }
225
+
205
226
  /** Whether this map holds an image worth sampling. False until a bake completes. */
206
227
  get hasBaked(): boolean {
207
228
  return this.baked;
@@ -401,6 +422,10 @@ export interface PointShadowSource {
401
422
  readonly far: number;
402
423
  readonly near: number;
403
424
  readonly sourceRadius: number;
425
+ /** Where the image was rendered from. See `PointShadowImage.originX`. */
426
+ readonly originX: number;
427
+ readonly originY: number;
428
+ readonly originZ: number;
404
429
  /** Whether this map holds an image worth sampling. */
405
430
  readonly hasBaked: boolean;
406
431
  /** How present the image is, 0 to 1. */
@@ -433,6 +458,28 @@ export interface ResolvedPointShadows {
433
458
  readonly far: Float32Array;
434
459
  readonly near: Float32Array;
435
460
  readonly sourceRadius: Float32Array;
461
+ /**
462
+ * The projection each bound map was rendered under: origin in `xyz`, far plane in `w`, four
463
+ * floats a light. The origin is **not** the light's position.
464
+ *
465
+ * A light is allowed to drift `pointShadowRebakeDistance` from its image before the image is
466
+ * re-rendered — that tolerance is what stops a flame re-baking six faces of the static world
467
+ * every frame — so for anything that flickers these two are apart on nearly every frame. The
468
+ * shader builds both its sample direction and the distance it compares from this point, so the
469
+ * ray it shoots is the ray the picture was drawn along and the drift costs nothing.
470
+ *
471
+ * **It used to build them from the live light**, and the drift went straight into the
472
+ * comparison: `dist` moved one for one with it against a bias of a few centimetres, and the
473
+ * single-tap blocker search moved across the occluder's silhouette, swapping `occluderDistance`
474
+ * between the caster and nothing. Reported on a brazier at night as a hard square under the
475
+ * fire, flashing in time with the flame while the camera stood still.
476
+ *
477
+ * **The far plane rides in `w` rather than in an array of its own**, because a default-block
478
+ * `float[N]` spends a whole uniform vector per element: declared separately the origin cost
479
+ * sixteen rows on each of the two sets, which the budget ladder answers on a 256-vector
480
+ * device by halving `MAX_LIGHTS`. See `uPointShadowProjection` in the preamble.
481
+ */
482
+ readonly projections: Float32Array;
436
483
  /** How present each bound map is, so a re-baked one arrives rather than appears. */
437
484
  readonly presence: Float32Array;
438
485
  /** The live pair, by the same light index: a light owns at most one of the two. */
@@ -440,6 +487,8 @@ export interface ResolvedPointShadows {
440
487
  readonly liveFar: Float32Array;
441
488
  readonly liveNear: Float32Array;
442
489
  readonly liveSourceRadius: Float32Array;
490
+ /** The live pair's own, by the same rule and for the same reason. */
491
+ readonly liveProjections: Float32Array;
443
492
  readonly liveWeights: Float32Array;
444
493
  }
445
494
 
@@ -460,11 +509,13 @@ export function createResolvedPointShadows(lights: number): ResolvedPointShadows
460
509
  far: new Float32Array(lights),
461
510
  near: new Float32Array(lights),
462
511
  sourceRadius: new Float32Array(lights),
512
+ projections: new Float32Array(lights * 4),
463
513
  presence: new Float32Array(lights),
464
514
  liveLayers: new Int32Array(lights).fill(-1),
465
515
  liveFar: new Float32Array(lights),
466
516
  liveNear: new Float32Array(lights),
467
517
  liveSourceRadius: new Float32Array(lights),
518
+ liveProjections: new Float32Array(lights * 4),
468
519
  liveWeights: new Float32Array(lights),
469
520
  };
470
521
  }
@@ -73,6 +73,19 @@ export class PointShadowMap {
73
73
  return this.image.sourceRadius;
74
74
  }
75
75
 
76
+ /** Where the image was rendered from, not where the light is now. See the image. */
77
+ get originX(): number {
78
+ return this.image.originX;
79
+ }
80
+
81
+ get originY(): number {
82
+ return this.image.originY;
83
+ }
84
+
85
+ get originZ(): number {
86
+ return this.image.originZ;
87
+ }
88
+
76
89
  /** Whether this map holds an image worth sampling. False until a bake completes. */
77
90
  get hasBaked(): boolean {
78
91
  return this.image.hasBaked;
@@ -343,6 +343,9 @@ export class PointShadowSystem<M extends PointShadowSource> {
343
343
  out.far.fill(1);
344
344
  out.near.fill(0.01);
345
345
  out.sourceRadius.fill(0);
346
+ /* Origin zero, far plane one: the same neutral the separate `far` array is filled with. */
347
+ out.projections.fill(0);
348
+ for (let k = 0; k < MAX_POINT_LIGHTS; k++) out.projections[k * 4 + 3] = 1;
346
349
  out.presence.fill(0);
347
350
 
348
351
  for (let k = 0; k < MAX_POINT_LIGHTS; k++) {
@@ -369,6 +372,11 @@ export class PointShadowSystem<M extends PointShadowSource> {
369
372
  out.far[light] = map.far;
370
373
  out.near[light] = map.near;
371
374
  out.sourceRadius[light] = map.sourceRadius;
375
+ /* Where the image was rendered from, not where the light is now. See `projections`. */
376
+ out.projections[light * 4] = map.originX;
377
+ out.projections[light * 4 + 1] = map.originY;
378
+ out.projections[light * 4 + 2] = map.originZ;
379
+ out.projections[light * 4 + 3] = map.far;
372
380
  out.presence[light] = map.presence;
373
381
  out.layers[light] = map.layer;
374
382
  }