@driftengine/core 4.1.3 → 4.1.5
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.
- package/CHANGELOG.json +3796 -3739
- package/README.md +1 -1
- package/dist/index.js +12 -2
- package/dist/render/backend/webgl2/renderer.d.ts +15 -0
- package/dist/render/backend/webgl2/renderer.js +28 -7
- package/dist/render/backend/webgpu/gpuDrivenPass.d.ts +46 -0
- package/dist/render/backend/webgpu/gpuDrivenPass.js +105 -0
- package/dist/render/backend/webgpu/pointShadowPass.d.ts +4 -0
- package/dist/render/backend/webgpu/pointShadowPass.js +10 -0
- package/dist/render/backend/webgpu/renderer.js +17 -4
- package/dist/render/depthConvention.d.ts +19 -0
- package/dist/render/depthConvention.js +21 -0
- package/dist/render/livePointShadowSet.js +8 -0
- package/dist/render/pointShadowBudget.js +59 -5
- package/dist/render/pointShadowImage.d.ts +40 -0
- package/dist/render/pointShadowImage.js +20 -0
- package/dist/render/pointShadowMap.d.ts +4 -0
- package/dist/render/pointShadowMap.js +10 -0
- package/dist/render/pointShadowSystem.js +9 -0
- package/dist/render/renderQuality.d.ts +27 -4
- package/dist/render/renderQuality.js +1 -1
- package/dist/render/shaders/flat/main.d.ts +1 -1
- package/dist/render/shaders/flat/main.js +104 -10
- package/dist/render/shaders/flat/pointShadow.d.ts +1 -1
- package/dist/render/shaders/flat/pointShadow.js +84 -46
- package/dist/render/shaders/flat/preamble.js +70 -9
- package/dist/render/shaders/generated/flat.wgsl.d.ts +16 -16
- package/dist/render/shaders/generated/flat.wgsl.js +32 -32
- package/dist/render/shaders/sky.d.ts +10 -6
- package/dist/render/shaders/sky.js +35 -2
- package/dist/render/textLayout.d.ts +58 -0
- package/dist/render/textLayout.js +83 -0
- package/dist/render/textRenderer.js +7 -3
- package/package.json +6 -6
- package/src/index.ts +12 -2
- package/src/render/backend/webgl2/renderer.ts +28 -7
- package/src/render/backend/webgpu/gpuDrivenPass.ts +105 -0
- package/src/render/backend/webgpu/pointShadowPass.ts +13 -0
- package/src/render/backend/webgpu/renderer.ts +26 -4
- package/src/render/depthConvention.ts +22 -0
- package/src/render/livePointShadowSet.ts +7 -0
- package/src/render/pointShadowBudget.ts +58 -5
- package/src/render/pointShadowImage.ts +51 -0
- package/src/render/pointShadowMap.ts +13 -0
- package/src/render/pointShadowSystem.ts +8 -0
- package/src/render/renderQuality.ts +28 -5
- package/src/render/shaders/flat/main.ts +104 -10
- package/src/render/shaders/flat/pointShadow.ts +84 -46
- package/src/render/shaders/flat/preamble.ts +70 -9
- package/src/render/shaders/generated/flat.wgsl.ts +32 -32
- package/src/render/shaders/sky.ts +37 -2
- package/src/render/textLayout.ts +88 -0
- package/src/render/textRenderer.ts +22 -3
|
@@ -1,10 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
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
|
-
|
|
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, ${
|
|
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
|
|
|
@@ -48,6 +48,64 @@ export declare const DEFAULT_TEXT_STYLE: TextStyle;
|
|
|
48
48
|
/** Width of a string in pixels at a given cell size, without laying it out. */
|
|
49
49
|
export declare function textWidthPx(text: string, cellSize: number): number;
|
|
50
50
|
export declare function textHeightPx(cellSize: number): number;
|
|
51
|
+
/**
|
|
52
|
+
* The cell size to draw at, so every cell of a bitmap glyph covers whole device pixels.
|
|
53
|
+
*
|
|
54
|
+
* **A 5x7 face draws every stroke exactly one cell wide, so a fractional cell is not a slightly
|
|
55
|
+
* softer glyph — it is a glyph whose strokes are different widths.** The rasteriser gives one
|
|
56
|
+
* column of cells four pixels and the next three, and a face carrying its legibility entirely in
|
|
57
|
+
* uniform strokes comes apart. Reported three times as text "eaten away" in a checkerboard while
|
|
58
|
+
* larger text in the same frame stayed solid, which is the size dependence this explains: a whole
|
|
59
|
+
* cell is uniform and a fractional one is not, and nothing about it is a dropped pixel.
|
|
60
|
+
*
|
|
61
|
+
* Two doors reach a fractional cell, which is why it was reported as unrelated faults. A caller
|
|
62
|
+
* asks for one outright, sizing a second line at a fraction of its headline; or asks for a whole
|
|
63
|
+
* one that the device ratio makes fractional, a 3-pixel cell at a 1.25 ratio being 3.75 on the
|
|
64
|
+
* grid. `viewportWidth` is whatever units the caller lays out in — CSS pixels for an overlay
|
|
65
|
+
* sized from `cssWidth`, device pixels for one sized from the canvas — and `bufferWidth` is the
|
|
66
|
+
* drawing buffer, so the ratio between them is exactly what turns one into the other.
|
|
67
|
+
*
|
|
68
|
+
* **What it gives up is the asked size.** The cell drawn is the largest whole number of device
|
|
69
|
+
* pixels that is not larger than the one asked for, so a string is drawn up to one device pixel
|
|
70
|
+
* per cell narrower than `textWidthPx` measures it. Down rather than to nearest, because a caller
|
|
71
|
+
* that fitted a line to a box measured it first: rounding up would draw a line wider than the box
|
|
72
|
+
* it was fitted to, and rounding down can only leave a gap.
|
|
73
|
+
*
|
|
74
|
+
* **`textWidthPx` and `RendererApi.textWidth` are deliberately left unsnapped**, and that is the
|
|
75
|
+
* cost rather than an oversight. Neither is handed a viewport, so neither can know whether the
|
|
76
|
+
* caller lays out in CSS pixels or device ones — and this ratio is exactly that question. A width
|
|
77
|
+
* is therefore an upper bound on what is drawn: a centred line sits up to half the shortfall off
|
|
78
|
+
* centre, at most half a device pixel per cell. Handing the measurement a viewport would fix it
|
|
79
|
+
* and is a change to the public surface rather than to a patch.
|
|
80
|
+
*
|
|
81
|
+
* **What would make it wrong** is a face whose strokes are not cell-aligned — an SDF atlas, where
|
|
82
|
+
* the glyph is a distance field and a fractional size is exactly what it is built to serve.
|
|
83
|
+
* `sdfTextLayout.ts` is that path and does not come through here.
|
|
84
|
+
*/
|
|
85
|
+
export declare function deviceSnappedCellSize(cellSize: number, viewportWidth: number, bufferWidth: number): number;
|
|
86
|
+
/**
|
|
87
|
+
* The same grid's **phase**, which is the other half of the same defect.
|
|
88
|
+
*
|
|
89
|
+
* **A whole pitch on a fractional origin still draws strokes of two widths.** Snapping the cell to
|
|
90
|
+
* four device pixels puts the boundaries four apart, and starting them at 10.4 puts every boundary
|
|
91
|
+
* at `.4` — so each stroke covers three whole pixels and two halves, and the rasteriser resolves
|
|
92
|
+
* that the same ragged way it resolved a fractional cell. The pitch decides how far apart the
|
|
93
|
+
* boundaries are; this decides where the first one is, and a face of uniform strokes needs both.
|
|
94
|
+
*
|
|
95
|
+
* It was found the way the first half was: the cell snap shipped, and the reporter said the text was
|
|
96
|
+
* still wrong.
|
|
97
|
+
*
|
|
98
|
+
* **To nearest rather than down**, which is the opposite of the cell above and for the opposite
|
|
99
|
+
* reason: this is a position, not a size. Moving a label up to half a device pixel is the smallest
|
|
100
|
+
* change that puts its grid on the display's, while flooring would shift every label the same way
|
|
101
|
+
* and bias a centred one off centre.
|
|
102
|
+
*
|
|
103
|
+
* **What would make it wrong** is a caller animating an origin sub-pixel on purpose — a label that
|
|
104
|
+
* slides smoothly now steps by a device pixel. For a face whose strokes are one pixel wide that is
|
|
105
|
+
* the trade this whole function exists to make, and `sdfTextLayout.ts`, which does not come through
|
|
106
|
+
* here, is the path for anything that wants the other answer.
|
|
107
|
+
*/
|
|
108
|
+
export declare function deviceSnappedOrigin(value: number, viewportWidth: number, bufferWidth: number): number;
|
|
51
109
|
export declare class TextLayout {
|
|
52
110
|
/** Reused every upload; text changes far too often to allocate per change. */
|
|
53
111
|
readonly cells: Float32Array<ArrayBuffer>;
|
|
@@ -38,6 +38,89 @@ export function textWidthPx(text, cellSize) {
|
|
|
38
38
|
export function textHeightPx(cellSize) {
|
|
39
39
|
return GLYPH_HEIGHT * cellSize;
|
|
40
40
|
}
|
|
41
|
+
/**
|
|
42
|
+
* The cell size to draw at, so every cell of a bitmap glyph covers whole device pixels.
|
|
43
|
+
*
|
|
44
|
+
* **A 5x7 face draws every stroke exactly one cell wide, so a fractional cell is not a slightly
|
|
45
|
+
* softer glyph — it is a glyph whose strokes are different widths.** The rasteriser gives one
|
|
46
|
+
* column of cells four pixels and the next three, and a face carrying its legibility entirely in
|
|
47
|
+
* uniform strokes comes apart. Reported three times as text "eaten away" in a checkerboard while
|
|
48
|
+
* larger text in the same frame stayed solid, which is the size dependence this explains: a whole
|
|
49
|
+
* cell is uniform and a fractional one is not, and nothing about it is a dropped pixel.
|
|
50
|
+
*
|
|
51
|
+
* Two doors reach a fractional cell, which is why it was reported as unrelated faults. A caller
|
|
52
|
+
* asks for one outright, sizing a second line at a fraction of its headline; or asks for a whole
|
|
53
|
+
* one that the device ratio makes fractional, a 3-pixel cell at a 1.25 ratio being 3.75 on the
|
|
54
|
+
* grid. `viewportWidth` is whatever units the caller lays out in — CSS pixels for an overlay
|
|
55
|
+
* sized from `cssWidth`, device pixels for one sized from the canvas — and `bufferWidth` is the
|
|
56
|
+
* drawing buffer, so the ratio between them is exactly what turns one into the other.
|
|
57
|
+
*
|
|
58
|
+
* **What it gives up is the asked size.** The cell drawn is the largest whole number of device
|
|
59
|
+
* pixels that is not larger than the one asked for, so a string is drawn up to one device pixel
|
|
60
|
+
* per cell narrower than `textWidthPx` measures it. Down rather than to nearest, because a caller
|
|
61
|
+
* that fitted a line to a box measured it first: rounding up would draw a line wider than the box
|
|
62
|
+
* it was fitted to, and rounding down can only leave a gap.
|
|
63
|
+
*
|
|
64
|
+
* **`textWidthPx` and `RendererApi.textWidth` are deliberately left unsnapped**, and that is the
|
|
65
|
+
* cost rather than an oversight. Neither is handed a viewport, so neither can know whether the
|
|
66
|
+
* caller lays out in CSS pixels or device ones — and this ratio is exactly that question. A width
|
|
67
|
+
* is therefore an upper bound on what is drawn: a centred line sits up to half the shortfall off
|
|
68
|
+
* centre, at most half a device pixel per cell. Handing the measurement a viewport would fix it
|
|
69
|
+
* and is a change to the public surface rather than to a patch.
|
|
70
|
+
*
|
|
71
|
+
* **What would make it wrong** is a face whose strokes are not cell-aligned — an SDF atlas, where
|
|
72
|
+
* the glyph is a distance field and a fractional size is exactly what it is built to serve.
|
|
73
|
+
* `sdfTextLayout.ts` is that path and does not come through here.
|
|
74
|
+
*/
|
|
75
|
+
export function deviceSnappedCellSize(cellSize, viewportWidth, bufferWidth) {
|
|
76
|
+
const ratio = bufferWidth / viewportWidth;
|
|
77
|
+
if (!Number.isFinite(ratio) || ratio <= 0)
|
|
78
|
+
return cellSize;
|
|
79
|
+
const device = cellSize * ratio;
|
|
80
|
+
if (!Number.isFinite(device))
|
|
81
|
+
return cellSize;
|
|
82
|
+
/*
|
|
83
|
+
* The tolerance is against float error rather than a fudge: a ratio of 1 turns an asked 4 into
|
|
84
|
+
* 3.9999999999999996 often enough, and flooring that would shrink every whole cell on every
|
|
85
|
+
* ordinary display by a pixel — the change doing most harm where there was nothing to fix.
|
|
86
|
+
*/
|
|
87
|
+
const whole = Math.floor(device + 1e-6);
|
|
88
|
+
/* Below one device pixel there is no whole size to snap to, and flooring would draw nothing. */
|
|
89
|
+
if (whole < 1)
|
|
90
|
+
return cellSize;
|
|
91
|
+
return whole / ratio;
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* The same grid's **phase**, which is the other half of the same defect.
|
|
95
|
+
*
|
|
96
|
+
* **A whole pitch on a fractional origin still draws strokes of two widths.** Snapping the cell to
|
|
97
|
+
* four device pixels puts the boundaries four apart, and starting them at 10.4 puts every boundary
|
|
98
|
+
* at `.4` — so each stroke covers three whole pixels and two halves, and the rasteriser resolves
|
|
99
|
+
* that the same ragged way it resolved a fractional cell. The pitch decides how far apart the
|
|
100
|
+
* boundaries are; this decides where the first one is, and a face of uniform strokes needs both.
|
|
101
|
+
*
|
|
102
|
+
* It was found the way the first half was: the cell snap shipped, and the reporter said the text was
|
|
103
|
+
* still wrong.
|
|
104
|
+
*
|
|
105
|
+
* **To nearest rather than down**, which is the opposite of the cell above and for the opposite
|
|
106
|
+
* reason: this is a position, not a size. Moving a label up to half a device pixel is the smallest
|
|
107
|
+
* change that puts its grid on the display's, while flooring would shift every label the same way
|
|
108
|
+
* and bias a centred one off centre.
|
|
109
|
+
*
|
|
110
|
+
* **What would make it wrong** is a caller animating an origin sub-pixel on purpose — a label that
|
|
111
|
+
* slides smoothly now steps by a device pixel. For a face whose strokes are one pixel wide that is
|
|
112
|
+
* the trade this whole function exists to make, and `sdfTextLayout.ts`, which does not come through
|
|
113
|
+
* here, is the path for anything that wants the other answer.
|
|
114
|
+
*/
|
|
115
|
+
export function deviceSnappedOrigin(value, viewportWidth, bufferWidth) {
|
|
116
|
+
const ratio = bufferWidth / viewportWidth;
|
|
117
|
+
if (!Number.isFinite(ratio) || ratio <= 0)
|
|
118
|
+
return value;
|
|
119
|
+
const device = value * ratio;
|
|
120
|
+
if (!Number.isFinite(device))
|
|
121
|
+
return value;
|
|
122
|
+
return Math.round(device) / ratio;
|
|
123
|
+
}
|
|
41
124
|
export class TextLayout {
|
|
42
125
|
/** Reused every upload; text changes far too often to allocate per change. */
|
|
43
126
|
cells = new Float32Array(MAX_CELLS * 2);
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { MAX_CELLS, TEXT_CUBE, TextLayout } from './textLayout.js';
|
|
1
|
+
import { MAX_CELLS, TEXT_CUBE, TextLayout, deviceSnappedCellSize, 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
|
/**
|
|
@@ -100,8 +100,12 @@ export class TextRenderer {
|
|
|
100
100
|
gl.enable(gl.BLEND);
|
|
101
101
|
gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
|
|
102
102
|
setVec2(gl, this.uniforms['uViewport'], viewportWidth, viewportHeight);
|
|
103
|
-
|
|
104
|
-
|
|
103
|
+
/* Snapped with the cell below: a whole pitch on a fractional origin still splits a stroke.
|
|
104
|
+
See `deviceSnappedOrigin`. */
|
|
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));
|
|
105
109
|
// Far enough that a whole line barely converges, near enough that a rotating character
|
|
106
110
|
// reads as turning rather than shearing.
|
|
107
111
|
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.
|
|
3
|
+
"version": "4.1.5",
|
|
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.
|
|
33
|
-
"@driftengine/physics": "4.1.
|
|
32
|
+
"@driftengine/drft": "4.1.5",
|
|
33
|
+
"@driftengine/physics": "4.1.5",
|
|
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.
|
|
67
|
-
"@driftengine/network": "4.1.
|
|
68
|
-
"@driftengine/texture": "4.1.
|
|
66
|
+
"@driftengine/nav": "4.1.5",
|
|
67
|
+
"@driftengine/network": "4.1.5",
|
|
68
|
+
"@driftengine/texture": "4.1.5"
|
|
69
69
|
}
|
|
70
70
|
}
|
package/src/index.ts
CHANGED
|
@@ -231,8 +231,18 @@ export type {
|
|
|
231
231
|
* A consumer who names neither pays nothing: `core-only` is measured with these exported and does
|
|
232
232
|
* not move, because nothing in the forward path reaches them.
|
|
233
233
|
*
|
|
234
|
-
* It draws vertex colour, one directional term, a hemispheric ambient and an emissive add — not
|
|
235
|
-
* the standard material
|
|
234
|
+
* **It draws vertex colour, one directional term, a hemispheric ambient and an emissive add — not
|
|
235
|
+
* the standard material**, and that is the sentence to read before reaching for it. There is no
|
|
236
|
+
* flag that routes `drawMesh` through this: a consumer packs a scene and contributes the pass, and
|
|
237
|
+
* `setMaterial`, `setSurfaceGrain`, `setSurfaceRelief` and a skin palette reach none of it. What
|
|
238
|
+
* would change that is a second copy of the standard material, which `gpuDrivenPass.ts` refuses in
|
|
239
|
+
* its own header.
|
|
240
|
+
*
|
|
241
|
+
* **The line that used to be here said `DRAFT_SCENES`, never `SCENES`, and it went stale the day
|
|
242
|
+
* the city was published.** `city` is in `SCENES` and says `pipelines: ['gpu-driven']`, so a host
|
|
243
|
+
* that cannot run the second pipeline reads that from the list rather than from a mount that
|
|
244
|
+
* refuses. The rule it was standing in for is the one above: what gates a scene is whether this
|
|
245
|
+
* pass can draw it, not which array it is in.
|
|
236
246
|
*/
|
|
237
247
|
export {
|
|
238
248
|
GPU_DRIVEN_LISTS,
|
|
@@ -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,
|
|
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
|
|
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
|
-
|
|
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.
|
|
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(
|
|
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(
|
|
5141
|
+
gl.clearDepth(this.depthClear);
|
|
5121
5142
|
this.pointShadowCasters = null;
|
|
5122
5143
|
this.depthPassCullsFaces = true;
|
|
5123
5144
|
}
|
|
@@ -737,6 +737,30 @@ export class GpuDrivenPass implements PassDefinition {
|
|
|
737
737
|
* before the device answers, which nobody sees; the alternative is a frame that draws nothing.
|
|
738
738
|
*/
|
|
739
739
|
private blendUsable = false;
|
|
740
|
+
/**
|
|
741
|
+
* The same question about *every other* pipeline in this pass, and the reason it exists is that
|
|
742
|
+
* 4.1.3 asked it about one.
|
|
743
|
+
*
|
|
744
|
+
* **The guard above was written from a log that named `gpu-driven blend`, and the four render
|
|
745
|
+
* pipelines and eight compute pipelines beside it were left with the shape that caused the
|
|
746
|
+
* report.** Reported again from an Android handset once that fix shipped: the transparent pass
|
|
747
|
+
* now skips itself correctly, the overlay still draws and the frame counter still counts — and
|
|
748
|
+
* the world is black, which is what an invalid *opaque* pipeline looks like once the blended one
|
|
749
|
+
* has stopped taking the command buffer with it.
|
|
750
|
+
*
|
|
751
|
+
* So the whole of `buildPipelines` is built inside one pair of scopes and nothing this pass
|
|
752
|
+
* encodes runs until they come back clean. The blend pipeline pushes its own pair *inside* that
|
|
753
|
+
* region, so the two verdicts stay separate: a device that refuses only the transparent pipeline
|
|
754
|
+
* still draws everything opaque, and a device that refuses anything else turns this pass off
|
|
755
|
+
* rather than encoding against a handle the driver has already rejected.
|
|
756
|
+
*
|
|
757
|
+
* **What it gives up** is the first frames, which draw nothing while the device is still
|
|
758
|
+
* answering. **What would make it wrong** is a device that never resolves an error scope; the
|
|
759
|
+
* `popErrorScope` check below is the same concession the blend guard makes, for the same reason.
|
|
760
|
+
*/
|
|
761
|
+
private usable = false;
|
|
762
|
+
/** Why this pass refused itself, in the words the device used, or null while it has not. */
|
|
763
|
+
private refusal: string | null = null;
|
|
740
764
|
/** Builds the transparent raster's bindings, held until there is a pipeline worth binding to. */
|
|
741
765
|
private makeBlendGroup: (() => GPUBindGroup) | null = null;
|
|
742
766
|
private blendResolvePipeline: GPURenderPipeline | null = null;
|
|
@@ -997,7 +1021,18 @@ export class GpuDrivenPass implements PassDefinition {
|
|
|
997
1021
|
});
|
|
998
1022
|
}
|
|
999
1023
|
this.buildBuffers(device.device);
|
|
1024
|
+
/*
|
|
1025
|
+
* **Every pipeline this pass owns, inside one pair of scopes.** See `usable`: the blend
|
|
1026
|
+
* pipeline pushes its own pair within this region, so its refusal stays its own and any other
|
|
1027
|
+
* refusal is caught here and turns the pass off instead of reaching an encoder.
|
|
1028
|
+
*/
|
|
1029
|
+
const scoped = typeof device.device.pushErrorScope === 'function';
|
|
1030
|
+
if (scoped) {
|
|
1031
|
+
device.device.pushErrorScope('validation');
|
|
1032
|
+
device.device.pushErrorScope('internal');
|
|
1033
|
+
}
|
|
1000
1034
|
this.buildPipelines(device.device, device.format, device.samples);
|
|
1035
|
+
this.watchPipelines(device.device, scoped);
|
|
1001
1036
|
if (this.width > 0) this.buildTargets(device.device);
|
|
1002
1037
|
}
|
|
1003
1038
|
|
|
@@ -1359,6 +1394,53 @@ export class GpuDrivenPass implements PassDefinition {
|
|
|
1359
1394
|
void device.popErrorScope().then((error) => settle(error, 'validation'));
|
|
1360
1395
|
}
|
|
1361
1396
|
|
|
1397
|
+
/**
|
|
1398
|
+
* Close the scopes every other pipeline was built in, and refuse the pass where one came back.
|
|
1399
|
+
*
|
|
1400
|
+
* **This is `watchBlendPipeline` widened to the rest of the pass**, and the reason it is separate
|
|
1401
|
+
* is that the two refusals mean different things: a blend pipeline nobody can compile costs the
|
|
1402
|
+
* glass, and a raster, shadow, blit or compute pipeline nobody can compile costs the world. The
|
|
1403
|
+
* first degrades; the second has nothing to degrade to, so it says so and draws nothing rather
|
|
1404
|
+
* than encoding a command buffer the driver will reject whole.
|
|
1405
|
+
*
|
|
1406
|
+
* A device with no scopes is taken at its word, exactly as the blend guard takes it: refusing to
|
|
1407
|
+
* draw on hardware that never said no is the worse of the two failures.
|
|
1408
|
+
*/
|
|
1409
|
+
private watchPipelines(device: GPUDevice, scoped: boolean): void {
|
|
1410
|
+
if (!scoped || typeof device.popErrorScope !== 'function') {
|
|
1411
|
+
this.usable = true;
|
|
1412
|
+
return;
|
|
1413
|
+
}
|
|
1414
|
+
let outstanding = 2;
|
|
1415
|
+
const settle = (error: GPUError | null, scope: string): void => {
|
|
1416
|
+
outstanding -= 1;
|
|
1417
|
+
if (error !== null && this.refusal === null) {
|
|
1418
|
+
this.refusal = error.message;
|
|
1419
|
+
console.warn(
|
|
1420
|
+
`[driftengine] this device refused a gpu-driven pipeline (${scope}), so this pass draws ` +
|
|
1421
|
+
`nothing and the rest of the frame is unaffected: ${error.message}`,
|
|
1422
|
+
);
|
|
1423
|
+
}
|
|
1424
|
+
/* Usable only once both have come back clean, which is what keeps an invalid handle out. */
|
|
1425
|
+
if (outstanding > 0 || this.refusal !== null) return;
|
|
1426
|
+
this.usable = true;
|
|
1427
|
+
};
|
|
1428
|
+
void device.popErrorScope().then((error) => settle(error, 'internal'));
|
|
1429
|
+
void device.popErrorScope().then((error) => settle(error, 'validation'));
|
|
1430
|
+
}
|
|
1431
|
+
|
|
1432
|
+
/**
|
|
1433
|
+
* Why this pass is drawing nothing, in the words the device used, or null where it is not.
|
|
1434
|
+
*
|
|
1435
|
+
* **Exposed because a black frame that says nothing is the bug this was reported as, twice.** A
|
|
1436
|
+
* pipeline refusal arrives asynchronously, long after `createRenderer` has decided the backend
|
|
1437
|
+
* can run this at all, so there is no earlier place to throw — and a scene that can read this can
|
|
1438
|
+
* tell somebody what happened instead of presenting an empty picture.
|
|
1439
|
+
*/
|
|
1440
|
+
refusedBecause(): string | null {
|
|
1441
|
+
return this.refusal;
|
|
1442
|
+
}
|
|
1443
|
+
|
|
1362
1444
|
private buildPipelines(device: GPUDevice, format: GPUTextureFormat, samples: number): void {
|
|
1363
1445
|
const compute = (label: string, code: string): GPUComputePipeline =>
|
|
1364
1446
|
device.createComputePipeline({
|
|
@@ -2420,6 +2502,8 @@ export class GpuDrivenPass implements PassDefinition {
|
|
|
2420
2502
|
|
|
2421
2503
|
prepare(ctx: PrepareContext): void {
|
|
2422
2504
|
if (ctx.backend !== 'webgpu') return;
|
|
2505
|
+
/* Nothing is encoded until the device has answered for the pipelines. See `usable`. */
|
|
2506
|
+
if (!this.usable) return;
|
|
2423
2507
|
const device = this.device;
|
|
2424
2508
|
if (device === null || this.levels.length === 0) return;
|
|
2425
2509
|
/* Before `writeFrame`, which reads what this took, and before the groups it may invalidate. */
|
|
@@ -2658,6 +2742,25 @@ export class GpuDrivenPass implements PassDefinition {
|
|
|
2658
2742
|
return;
|
|
2659
2743
|
}
|
|
2660
2744
|
case 'blendResolve': {
|
|
2745
|
+
/*
|
|
2746
|
+
* **Skipped on the same verdict as the draw, because the draw is what clears what this
|
|
2747
|
+
* reads.** This is the whole of the reported black frame, and the line above it predicted
|
|
2748
|
+
* it: the blend pass clears reveal to one, meaning nothing has covered this pixel yet, and
|
|
2749
|
+
* says in as many words that cleared to zero "the resolve would show no scene anywhere the
|
|
2750
|
+
* pass ran, which is a black frame that looks like the blend working".
|
|
2751
|
+
*
|
|
2752
|
+
* 4.1.2 skipped the draw and took its clear with it, and left this running against textures
|
|
2753
|
+
* nothing had written — a reveal of zero over every pixel, which is exactly "fully covered".
|
|
2754
|
+
* Reported from a Galaxy S23 Ultra and then an Adreno 740, both of which refuse only
|
|
2755
|
+
* `gpu-driven blend` and both of which drew the city into a buffer this then wiped: the
|
|
2756
|
+
* page's own readout said 43 fps and 1,087 clusters drawn while the screen was black.
|
|
2757
|
+
*
|
|
2758
|
+
* There is nothing to composite when nothing was rasterised, so the honest answer is not to
|
|
2759
|
+
* composite. **What would make it wrong** is a caller that expects this pass to have run for
|
|
2760
|
+
* some other reason; nothing does, and the opaque image is already in `colourView` before
|
|
2761
|
+
* this would load it.
|
|
2762
|
+
*/
|
|
2763
|
+
if (!this.blendUsable) return;
|
|
2661
2764
|
const pass = encoder.beginRenderPass({
|
|
2662
2765
|
label: 'gpu-driven blend resolve',
|
|
2663
2766
|
colorAttachments: [
|
|
@@ -2988,6 +3091,8 @@ export class GpuDrivenPass implements PassDefinition {
|
|
|
2988
3091
|
|
|
2989
3092
|
draw(ctx: PassContext): void {
|
|
2990
3093
|
if (ctx.backend !== 'webgpu') return;
|
|
3094
|
+
/* The same gate as `prepare`: an unanswered or refused pipeline set encodes nothing at all. */
|
|
3095
|
+
if (!this.usable) return;
|
|
2991
3096
|
if (this.blitPipeline === null || this.blitGroup === null) return;
|
|
2992
3097
|
if (ctx.outputTransform !== 0 && !this.gradeWarned) {
|
|
2993
3098
|
this.gradeWarned = true;
|
|
@@ -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,6 +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
154
|
import { GpuSurfaceTexture } from './surfaceTexturePass.ts';
|
|
154
155
|
import {
|
|
155
156
|
createSdfTextBindGroup,
|
|
@@ -3178,8 +3179,19 @@ export class WebGPURenderer implements RendererApi {
|
|
|
3178
3179
|
const at = (name: string): number => TEXT_VERT_FIELDS[name]?.offset ?? 0;
|
|
3179
3180
|
const v = this.textVerts;
|
|
3180
3181
|
v.writeFloats(vertexSlot, at('uViewport'), [viewportWidth, viewportHeight]);
|
|
3181
|
-
|
|
3182
|
-
|
|
3182
|
+
/* Snapped with the cell below: a whole pitch on a fractional origin still splits a stroke.
|
|
3183
|
+
See `deviceSnappedOrigin`. */
|
|
3184
|
+
v.writeFloats(vertexSlot, at('uOrigin'), [
|
|
3185
|
+
deviceSnappedOrigin(originX, viewportWidth, this.surface.canvas.width),
|
|
3186
|
+
deviceSnappedOrigin(originY, viewportHeight, this.surface.canvas.height),
|
|
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
|
+
);
|
|
3183
3195
|
/* Far enough that a whole line barely converges, near enough that a turn reads as a turn. */
|
|
3184
3196
|
v.writeFloat(vertexSlot, at('uDepth'), Math.max(viewportWidth, 600) * 1.4);
|
|
3185
3197
|
v.writeFloat(vertexSlot, at('uReveal'), style.reveal);
|
|
@@ -7708,7 +7720,12 @@ export class WebGPURenderer implements RendererApi {
|
|
|
7708
7720
|
this.clearShadowSelection(f, i, 'uLivePointShadowLayer', 'uLivePointShadowWeight');
|
|
7709
7721
|
} else {
|
|
7710
7722
|
this.writeShadowSelection(f, i, 'uPointShadow', resolved.layers, resolved.presence);
|
|
7711
|
-
|
|
7723
|
+
/*
|
|
7724
|
+
* A `vec4` row carrying the bake origin and the far plane, so `scatterInto` rather than
|
|
7725
|
+
* `writeShadowParams` — it reads the stride out of the generated layout instead of
|
|
7726
|
+
* counting rows, which is what AGENTS.md's 2026-09-20 alignment rule asks for.
|
|
7727
|
+
*/
|
|
7728
|
+
scatterInto(f, this.fragment.fields['uPointShadowProjection'], resolved.projections, 4);
|
|
7712
7729
|
this.writeShadowParams(f, 'uPointShadowNear', resolved.near);
|
|
7713
7730
|
this.writeShadowParams(f, 'uPointShadowSize', resolved.sourceRadius);
|
|
7714
7731
|
this.writeShadowSelection(
|
|
@@ -7718,7 +7735,12 @@ export class WebGPURenderer implements RendererApi {
|
|
|
7718
7735
|
resolved.liveLayers,
|
|
7719
7736
|
resolved.liveWeights,
|
|
7720
7737
|
);
|
|
7721
|
-
|
|
7738
|
+
scatterInto(
|
|
7739
|
+
f,
|
|
7740
|
+
this.fragment.fields['uLivePointShadowProjection'],
|
|
7741
|
+
resolved.liveProjections,
|
|
7742
|
+
4,
|
|
7743
|
+
);
|
|
7722
7744
|
this.writeShadowParams(f, 'uLivePointShadowNear', resolved.liveNear);
|
|
7723
7745
|
this.writeShadowParams(f, 'uLivePointShadowSize', resolved.liveSourceRadius);
|
|
7724
7746
|
}
|