@driftengine/core 4.1.3 → 4.1.4
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 +328 -297
- package/README.md +1 -1
- package/dist/index.js +12 -2
- package/dist/render/backend/webgpu/gpuDrivenPass.d.ts +46 -0
- package/dist/render/backend/webgpu/gpuDrivenPass.js +105 -0
- package/dist/render/backend/webgpu/renderer.js +10 -2
- package/dist/render/shaders/flat/main.d.ts +1 -1
- package/dist/render/shaders/flat/main.js +91 -5
- package/dist/render/shaders/flat/pointShadow.d.ts +1 -1
- package/dist/render/shaders/flat/pointShadow.js +42 -16
- package/dist/render/shaders/flat/preamble.js +44 -7
- package/dist/render/shaders/generated/flat.wgsl.js +16 -16
- 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/webgpu/gpuDrivenPass.ts +105 -0
- package/src/render/backend/webgpu/renderer.ts +14 -2
- package/src/render/shaders/flat/main.ts +91 -5
- package/src/render/shaders/flat/pointShadow.ts +42 -16
- package/src/render/shaders/flat/preamble.ts +44 -7
- package/src/render/shaders/generated/flat.wgsl.ts +16 -16
- package/src/render/textLayout.ts +88 -0
- package/src/render/textRenderer.ts +22 -3
|
@@ -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.4",
|
|
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.4",
|
|
33
|
+
"@driftengine/physics": "4.1.4",
|
|
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.4",
|
|
67
|
+
"@driftengine/network": "4.1.4",
|
|
68
|
+
"@driftengine/texture": "4.1.4"
|
|
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,
|
|
@@ -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;
|
|
@@ -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);
|
|
@@ -793,6 +793,27 @@ export const MAIN_GLSL = `void main() {
|
|
|
793
793
|
*/
|
|
794
794
|
float lightShade = 1.0;
|
|
795
795
|
|
|
796
|
+
/*
|
|
797
|
+
* **The lamps' light twice: once as it arrives, once as their shadows leave it.**
|
|
798
|
+
*
|
|
799
|
+
* Every point light's contribution used to go straight into \`lit\`. It is accumulated here
|
|
800
|
+
* instead, in two sums that differ only by the shadow term, so that after the loop there is
|
|
801
|
+
* a single number per channel saying how much of the lamps' light survived — and *that* is
|
|
802
|
+
* the thing the resolve below can average across a pair of pixels without touching albedo,
|
|
803
|
+
* a highlight, or anything else the frame has in it.
|
|
804
|
+
*
|
|
805
|
+
* **The two sums are not an approximation.** \`lampOpen\` multiplied by
|
|
806
|
+
* \`lampShadowed / lampOpen\` is \`lampShadowed\`, per channel, whatever mixture of lamps
|
|
807
|
+
* and shadows produced it. With the resolve switched off this is arithmetically the line it
|
|
808
|
+
* replaces; a lamp that is shadowed and a lamp beside it that is not keep their own shares.
|
|
809
|
+
*
|
|
810
|
+
* **What it costs** is two vector adds per light and six floats of live state. **What would
|
|
811
|
+
* make it wrong** is a term wanting to sit between the lamps and \`lit\` — a per-light
|
|
812
|
+
* tone map, say — because these two are summed before anything can get between them.
|
|
813
|
+
*/
|
|
814
|
+
vec3 lampOpen = vec3(0.0);
|
|
815
|
+
vec3 lampShadowed = vec3(0.0);
|
|
816
|
+
|
|
796
817
|
/*
|
|
797
818
|
* Which froxel this fragment is in.
|
|
798
819
|
*
|
|
@@ -1192,7 +1213,13 @@ export const MAIN_GLSL = `void main() {
|
|
|
1192
1213
|
*
|
|
1193
1214
|
* At metal 0 this is one multiply by 1.0 away from the expression it replaces.
|
|
1194
1215
|
*/
|
|
1195
|
-
|
|
1216
|
+
/*
|
|
1217
|
+
* Held back from \`lit\` until the loop is over, so the shadow can be resolved across a
|
|
1218
|
+
* pair of pixels first. See the block below the loop; nothing else about this term moved.
|
|
1219
|
+
*/
|
|
1220
|
+
vec3 lampDiffuse = albedo * lightColor * ndl * shape * lightWeight * (1.0 - metal);
|
|
1221
|
+
lampOpen += lampDiffuse;
|
|
1222
|
+
lampShadowed += lampDiffuse * shaded;
|
|
1196
1223
|
|
|
1197
1224
|
/*
|
|
1198
1225
|
* A lamp reflected in a polished surface, which point lights could not do at all.
|
|
@@ -1222,8 +1249,12 @@ export const MAIN_GLSL = `void main() {
|
|
|
1222
1249
|
vec3(1.0),
|
|
1223
1250
|
pow(1.0 - max(dot(toEyeLamp, lampHalfway), 0.0), 5.0) * metal
|
|
1224
1251
|
);
|
|
1225
|
-
|
|
1226
|
-
|
|
1252
|
+
/* Held back with the diffuse half, and shadowed by the same term it always was. */
|
|
1253
|
+
vec3 lampHighlight =
|
|
1254
|
+
lightColor * sphereLobe(max(dot(n, lampHalfway), 0.0), surfaceRoughness, lightSourceRadius, dist)
|
|
1255
|
+
* lampSpec * shape * lightWeight;
|
|
1256
|
+
lampOpen += lampHighlight;
|
|
1257
|
+
lampShadowed += lampHighlight * shaded;
|
|
1227
1258
|
}
|
|
1228
1259
|
/*
|
|
1229
1260
|
* The most any light in range shadows this point, for the emissive term below —
|
|
@@ -1371,7 +1402,10 @@ export const MAIN_GLSL = `void main() {
|
|
|
1371
1402
|
}
|
|
1372
1403
|
#endif
|
|
1373
1404
|
|
|
1374
|
-
|
|
1405
|
+
/* Held back with the point lamps', and shadowed by the same term it always was. */
|
|
1406
|
+
vec3 areaDiffuse = albedo * uAreaLightColor[a] * form * (1.0 - metal);
|
|
1407
|
+
lampOpen += areaDiffuse;
|
|
1408
|
+
lampShadowed += areaDiffuse * areaOccl;
|
|
1375
1409
|
|
|
1376
1410
|
if (vSpecular > 0.0 || metal > 0.0) {
|
|
1377
1411
|
/*
|
|
@@ -1396,11 +1430,63 @@ export const MAIN_GLSL = `void main() {
|
|
|
1396
1430
|
if (coverage > 0.0) {
|
|
1397
1431
|
vec2 areaDfg = envBrdfApprox(max(dot(n, toEyeArea), 0.0), surfaceRoughness);
|
|
1398
1432
|
vec3 areaSpec = specColor * areaDfg.x + vec3(areaDfg.y);
|
|
1399
|
-
|
|
1433
|
+
vec3 areaHighlight = areaSpec * uAreaLightColor[a] * coverage;
|
|
1434
|
+
lampOpen += areaHighlight;
|
|
1435
|
+
lampShadowed += areaHighlight * areaOccl;
|
|
1400
1436
|
}
|
|
1401
1437
|
}
|
|
1402
1438
|
}
|
|
1403
1439
|
|
|
1440
|
+
/*
|
|
1441
|
+
* **The other half of the shadow filter: the two pixels of a column pair, folded together.**
|
|
1442
|
+
*
|
|
1443
|
+
* \`pointShadow\` turns its twelve taps by half a golden angle on every other column, so a
|
|
1444
|
+
* pair of neighbouring pixels holds twenty-four places on the disk between them and neither
|
|
1445
|
+
* holds more than twelve. This is where the pair becomes one filter. \`dFdx\` of a value is
|
|
1446
|
+
* the difference across the pair, and half of it, signed by which of the two this pixel is,
|
|
1447
|
+
* is exactly the average of both — so each pixel ends up shaded by all twenty-four taps
|
|
1448
|
+
* while paying for twelve. It is the pairing \`ambientOcclusion.ts\` describes between its
|
|
1449
|
+
* rotation tile and its blur, at the smallest size that needs no second pass.
|
|
1450
|
+
*
|
|
1451
|
+
* **After both lamp loops rather than beside the taps, and that is why this block exists at
|
|
1452
|
+
* all.** A derivative is only defined where every pixel of the quad reaches it, and neither
|
|
1453
|
+
* loop above reaches anything uniformly: the point loop \`continue\`s on falloff and on
|
|
1454
|
+
* facing, and in the clustered arm its trip count is read from a froxel, so two neighbouring
|
|
1455
|
+
* pixels can be shading different lights with a froxel boundary drawn across the screen
|
|
1456
|
+
* where they stop agreeing. A \`dFdx\` in there is the 2026-08-07 rule's own case, and it
|
|
1457
|
+
* would also invite a compiler to flatten the loop. Measured, too: with the turn taken from
|
|
1458
|
+
* a per-fragment hash instead of the column, the same resolve made the penumbra *worse*
|
|
1459
|
+
* than no resolve at all — 3.14 against 3.00 — because the hardware here answers a coarse
|
|
1460
|
+
* derivative and the second row of a quad then borrows the first row's difference. The
|
|
1461
|
+
* column tile is what makes that borrowing correct, so the two halves are one mechanism.
|
|
1462
|
+
*
|
|
1463
|
+
* Both lamp kinds are in the sums, because a rectangle's shadow is filtered by the same
|
|
1464
|
+
* twelve taps and stippled the same way.
|
|
1465
|
+
*
|
|
1466
|
+
* **Clamped, and the clamp is a guard rather than tidying.** A fragment killed by the
|
|
1467
|
+
* cutout or the clip plane above leaves its lane without a value for its neighbour to
|
|
1468
|
+
* difference against, and GLSL ES says nothing about what comes back. What could reach the
|
|
1469
|
+
* frame is a shadow term outside 0..1 on the pixel beside a cutout hole, which the clamp
|
|
1470
|
+
* turns into a pixel that is merely fully lit or fully shadowed. **What would make this
|
|
1471
|
+
* wrong** is a consumer whose cutout edges are most of the frame — dense foliage drawn as
|
|
1472
|
+
* one alpha-tested sheet — where the honest answer is to resolve in a pass of its own,
|
|
1473
|
+
* against a shadow term written to a target.
|
|
1474
|
+
*
|
|
1475
|
+
* The floor under the divisor costs nothing and answers the case where no lamp reached this
|
|
1476
|
+
* pixel at all: both sums are zero there, so the ratio is zero and multiplying it back by
|
|
1477
|
+
* the floor adds nothing.
|
|
1478
|
+
*/
|
|
1479
|
+
vec3 lampOpenFloor = max(lampOpen, vec3(1e-6));
|
|
1480
|
+
vec3 lampShade = lampShadowed / lampOpenFloor;
|
|
1481
|
+
#if POINT_SHADOWS
|
|
1482
|
+
lampShade = clamp(
|
|
1483
|
+
lampShade - dFdx(lampShade) * (float(int(gl_FragCoord.x) & 1) - 0.5),
|
|
1484
|
+
0.0,
|
|
1485
|
+
1.0
|
|
1486
|
+
);
|
|
1487
|
+
#endif
|
|
1488
|
+
lit += lampOpenFloor * lampShade;
|
|
1489
|
+
|
|
1404
1490
|
/*
|
|
1405
1491
|
* Self-illuminated geometry, dimmed where a lamp's shadow crosses it.
|
|
1406
1492
|
*
|
|
@@ -116,8 +116,8 @@ export const POINTSHADOW_GLSL = `float pointShadow(
|
|
|
116
116
|
float strength = 1.0 - penumbra * penumbra;
|
|
117
117
|
|
|
118
118
|
/*
|
|
119
|
-
* Taps are placed on a disk *perpendicular to the light ray*,
|
|
120
|
-
*
|
|
119
|
+
* Taps are placed on a disk *perpendicular to the light ray*, turned by half a golden
|
|
120
|
+
* angle on every other column of pixels.
|
|
121
121
|
*
|
|
122
122
|
* Offsetting the ray by fixed 3D vectors was the earlier approach and it has
|
|
123
123
|
* two faults that only show on a wide filter. The offsets are not
|
|
@@ -127,8 +127,33 @@ export const POINTSHADOW_GLSL = `float pointShadow(
|
|
|
127
127
|
* tap pattern — reported, on a brazier behind a rank of columns, as strange
|
|
128
128
|
* straight lines across the light pool.
|
|
129
129
|
*
|
|
130
|
-
*
|
|
131
|
-
*
|
|
130
|
+
* **The cure for that was a per-fragment hash, and it is what made the penumbra
|
|
131
|
+
* stipple.** Twelve taps are a coarse estimate of how much of the emitter this point can
|
|
132
|
+
* see; turning them by an unrelated angle at every pixel makes each pixel's error
|
|
133
|
+
* independent of its neighbour's, which is the definition of noise. Something has to
|
|
134
|
+
* average that back out, and this engine's lit pass is the last thing to touch the frame —
|
|
135
|
+
* there is no blur and no temporal resolve behind it, so the noise reached the screen. Two
|
|
136
|
+
* games reported the same coarse cross-hatch across a light pool.
|
|
137
|
+
*
|
|
138
|
+
* **So the turn comes from a two-pixel tile instead, and the pair is averaged afterwards.**
|
|
139
|
+
* A pixel's own x parity picks one of two tap sets half a golden angle apart, and
|
|
140
|
+
* \`main\` folds the pair together once the light loop is over — see the resolve there. The
|
|
141
|
+
* pair's twenty-four taps are the filter; each pixel pays for twelve of them.
|
|
142
|
+
*
|
|
143
|
+
* **A *column* pair rather than a 2x2 square, and that was measured rather than assumed.**
|
|
144
|
+
* Four turns on a square tile, resolved with both derivatives, came back at 3.88 against this
|
|
145
|
+
* pair's 2.46 on the same build — because the second derivative hands the lower row of a quad
|
|
146
|
+
* the upper row's difference on hardware that answers a coarse derivative, and with four turns
|
|
147
|
+
* that is the wrong difference. Two turns keyed on the column alone make it the right one,
|
|
148
|
+
* which is why the tile's shape and the resolve are one decision rather than two.
|
|
149
|
+
*
|
|
150
|
+
* **Neither half works alone**, which is the same pairing \`ambientOcclusion.ts\` describes
|
|
151
|
+
* between its rotation tile and its blur. Measured on \`demo/dev/pointshadow.html\` at
|
|
152
|
+
* \`?exposure=6\`, as mean absolute luminance difference to the right and down neighbour over
|
|
153
|
+
* the mover's penumbra, on WebGL2: **4.60** for the per-fragment hash this replaces, **2.88**
|
|
154
|
+
* for this tile with no resolve — and a one-pixel vertical comb in the picture, which is the
|
|
155
|
+
* tile itself on screen — against **2.37** for the pair resolved. A frame with the shadow
|
|
156
|
+
* switched off measures 0.81, and WebGPU answers 4.56 and 2.33 for the same two builds.
|
|
132
157
|
*
|
|
133
158
|
* **Unchanged by the move to an octahedral map, and that is the point.** The offsets are
|
|
134
159
|
* angular perturbations of a direction, so every tap is still a direction and octEncode is
|
|
@@ -139,17 +164,19 @@ export const POINTSHADOW_GLSL = `float pointShadow(
|
|
|
139
164
|
vec3 up = abs(fwd.y) < 0.9 ? vec3(0.0, 1.0, 0.0) : vec3(1.0, 0.0, 0.0);
|
|
140
165
|
vec3 tu = normalize(cross(up, fwd));
|
|
141
166
|
vec3 tv = cross(fwd, tu);
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
167
|
+
/*
|
|
168
|
+
* Integer arithmetic, because this is a lattice: a shader compiler is free to implement
|
|
169
|
+
* \`mod(x, 2.0)\` as a multiply by a reciprocal, and a GPU here answers 3 for \`3.0 % 3.0\`.
|
|
170
|
+
* \`gl_FragCoord.x\` is the pixel's centre, so truncating it is the pixel's own column.
|
|
171
|
+
*/
|
|
172
|
+
float angle = float(int(gl_FragCoord.x) & 1) * PCF_PAIR_TURN;
|
|
146
173
|
float ca = cos(angle);
|
|
147
174
|
float sa = sin(angle);
|
|
148
175
|
|
|
149
176
|
float lit = 0.0;
|
|
150
177
|
for (int i = 0; i < ${MAX_SHADOW_FILTER_TAPS}; i++) {
|
|
151
178
|
if (i >= uShadowFilterTaps) break;
|
|
152
|
-
vec2 disk = PCF_OFFSETS[i]
|
|
179
|
+
vec2 disk = PCF_OFFSETS[i];
|
|
153
180
|
vec2 turned = vec2(disk.x * ca - disk.y * sa, disk.x * sa + disk.y * ca);
|
|
154
181
|
// Perpendicular to the ray, and *not* scaled by its length: the offsets are
|
|
155
182
|
// an angular perturbation of a direction, which is what the encoding takes.
|
|
@@ -267,20 +294,19 @@ float areaShadow(
|
|
|
267
294
|
float strength = 1.0 - penumbra * penumbra;
|
|
268
295
|
|
|
269
296
|
/*
|
|
270
|
-
*
|
|
271
|
-
*
|
|
272
|
-
*
|
|
273
|
-
*
|
|
297
|
+
* Turned inside the unit disk *before* the ellipse maps it, so the half-turn that separates
|
|
298
|
+
* the two halves of a pixel pair survives the anisotropy. The same two-pixel tile the round
|
|
299
|
+
* filter uses and for the same reasons — see \`pointShadow\`, which carries the measurement;
|
|
300
|
+
* a rectangle's shadow stippled exactly as a lamp's did, because it is the same twelve taps.
|
|
274
301
|
*/
|
|
275
|
-
float angle =
|
|
276
|
-
* 6.28318530718;
|
|
302
|
+
float angle = float(int(gl_FragCoord.x) & 1) * PCF_PAIR_TURN;
|
|
277
303
|
float ca = cos(angle);
|
|
278
304
|
float sa = sin(angle);
|
|
279
305
|
|
|
280
306
|
float lit = 0.0;
|
|
281
307
|
for (int i = 0; i < ${MAX_SHADOW_FILTER_TAPS}; i++) {
|
|
282
308
|
if (i >= uShadowFilterTaps) break;
|
|
283
|
-
vec2 disk = PCF_OFFSETS[i]
|
|
309
|
+
vec2 disk = PCF_OFFSETS[i];
|
|
284
310
|
vec2 turned = vec2(disk.x * ca - disk.y * sa, disk.x * sa + disk.y * ca);
|
|
285
311
|
vec3 sampleDir = toFrag + alongX * (turned.x * widthX) + alongY * (turned.y * widthY);
|
|
286
312
|
float stored = textureLod(maps, vec3(octEncode(sampleDir), layer), 0.0).r;
|