@driftengine/core 4.1.2 → 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 +52 -0
- package/README.md +1 -1
- package/dist/index.js +12 -2
- package/dist/render/backend/webgpu/gpuDrivenPass.d.ts +62 -0
- package/dist/render/backend/webgpu/gpuDrivenPass.js +192 -45
- 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 +195 -47
- 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
package/CHANGELOG.json
CHANGED
|
@@ -1,4 +1,56 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "4.1.4",
|
|
4
|
+
"date": "2026-09-21",
|
|
5
|
+
"entries": [
|
|
6
|
+
{
|
|
7
|
+
"kind": "fixed",
|
|
8
|
+
"short": "A lamp casts a smooth penumbra. Twelve filter taps were asking eight questions, because the offsets were a cube flattened onto a plane and four pairs of them landed on each other.",
|
|
9
|
+
"text": "**Reported as grainy, stippled shadows in two unrelated games on this engine**, at a desktop profile with the full twelve taps. The approved repair was a rotation tile and a resolve, and underneath it was a second cause nobody had measured.\n\n**`PCF_OFFSETS` declared twelve taps and asked eight questions.** It was a cube's eight corners and six faces, carried as `vec3` from the era when a tap offset a direction by a 3D vector. Both filters have read only `.xy` since the map became octahedral, and flattening a cube's corners onto a plane makes four pairs coincide: four taps were answered twice and the closest pair sat at a distance of **zero**. The eight distinct places were all on the rim, at radius 1 or 1.414, with nothing in between — so a coverage estimate moved in steps of two twelfths as the penumbra swept a doubled pair across it, which is the size of the speckle.\n\n**A golden-angle disk of twelve distinct places replaces it**, the construction `ambientOcclusion.ts` already uses, with the radii dealt round rather than taken in spiral order: `shadowFilterTaps` is 4, 8 or 12 and the loop stops early, so in spiral order a low profile would quietly have received the four innermost taps and a third of the width it asked for. The outermost tap is 1.384 against the old 1.414, so the filter's reach does not move.\n\n**The turn and the resolve are one mechanism.** Two tap sets half a golden angle apart, chosen by the fragment's column parity, folded together after both lamp loops. The resolve cannot sit beside the taps: that loop continues on falloff and on facing and reads its trip count from a froxel, so neighbouring pixels can be shading different lights, and a derivative there is the 2026-08-07 rule's own case. After the loops the control flow has reconverged. Each pixel is shaded by the pair's twenty-four places and pays for twelve.\n\nMeasured on the mover's penumbra, mean absolute difference to the right and down neighbour: **4.563 to 2.327 on WebGPU and 4.595 to 2.372 on WebGL2**, against a floor of 0.805 with the shadow off. The area-light path is included, because it uses the same taps and stippled the same way. Cost is nothing measurable: five capture pairs on `night-court` put the before-and-after difference at most 0.08 ms against a round-to-round spread of 0.53.\n\n**The banding the turn was added to cure has not returned**, checked on the control it was reported from: with the turn deleted the post's long shadow terraces, and the shipped build is smooth with no straight edge."
|
|
10
|
+
},
|
|
11
|
+
{
|
|
12
|
+
"kind": "fixed",
|
|
13
|
+
"short": "A bitmap glyph draws strokes of one width. A cell that is a fractional number of device pixels gives one column four pixels and the next three, and nothing is ever dropped.",
|
|
14
|
+
"text": "**Reported as pixelated text across three unrelated surfaces** — a game's world label, a wordmark, and this repository's own voxel sandbox. It is not about missing pixels, which is why a hole count found none: a 5x7 face draws every stroke exactly one cell wide, so when a cell is a fractional number of device pixels the rasteriser gives one column of cells four pixels and the next three, and a face whose whole legibility is uniform strokes comes apart.\n\n**Two doors reach a fractional cell**, which is why it arrived as three unrelated faults. A caller can ask for one outright — a consumer sizing a detail line at 0.52 of a headline of 9 asks for 4.68, which needs no unusual display at all. Or a caller can ask for a whole cell that the ratio makes fractional, by laying out in CSS pixels at a device ratio of 1.25. A pixel budget that scales the drawing buffer by a square root and floors it is a third route, and a player can reach it from a settings screen.\n\nThe tell is that **cell 3.75 carries more ink than cell 4.00** — more lit pixels from a smaller cell, which is impossible if the face were merely small.\n\n**`deviceSnappedCellSize` draws the largest whole number of device pixels not larger than the one asked for**, decided in shared code with only the binding per backend. Down rather than to nearest, because a caller that fitted a line to a box measured it first: rounding up draws wider than the box, rounding down can only leave a gap.\n\n**And the grid has a phase as well as a pitch**, which the first half of this fix left alone and the reporter caught: a whole four-pixel cell starting at 10.4 puts every boundary at four tenths and splits the strokes again. `deviceSnappedOrigin` snaps that too, to nearest rather than down, because a cell is a size and an origin is a position.\n\n**What it costs**: `textWidth` is not snapped, because it is handed no viewport and so cannot know the ratio, which makes a measured width an upper bound and a centred line sit up to half a device pixel per cell off centre. And a label animating its position sub-pixel now steps by a device pixel rather than sliding. SDF text does not come through this path."
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
"kind": "fixed",
|
|
18
|
+
"short": "A device that refuses any GPU-driven pipeline loses that pass and not the frame. 4.1.3 guarded one pipeline of thirteen, and the next handset reported the half that was left.",
|
|
19
|
+
"text": "**4.1.3 was written from a log that named `gpu-driven blend`**, and it wrapped that pipeline alone. The four other render pipelines and the eight compute pipelines beside it kept the shape that caused the report, and the next device reported what was left of it: the transparent pass correctly skipping itself, the overlay still drawing, the frame counter still counting, and **the world black** — which is what an invalid opaque pipeline looks like once the blended one has stopped taking the command buffer with it.\n\n**The whole of `buildPipelines` is built inside one pair of error scopes now**, with the blend pipeline's own pair nested inside. The two verdicts stay separate: a device that refuses only the transparent pipeline still draws everything opaque, and a device that refuses anything else turns the pass off rather than encoding against a handle the driver has already rejected. Nothing this pass encodes runs until the scopes come back clean.\n\n**And a scene can say why it is black.** `refusedBecause` reports the refusal in the device's own words, and `DemoHandle.refused` carries it to a host — the same shape as the `lost` flag beside it, one cause along. A pipeline refusal arrives asynchronously, long after `createRenderer` has decided the backend can run the pass at all, so there is no earlier place to throw and nothing for a host to catch.\n\n**The fixture had to be corrected before the test meant anything.** It stubbed `popErrorScope` without `pushErrorScope`, which is a device that reports errors nobody asked it to collect. No device is that, and the guard correctly took such a device at its word and never ran — a green test over an unexercised guard."
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
"kind": "fixed",
|
|
23
|
+
"short": "The offline hierarchical-detail bake is reachable. It was tested, sized and licensed, and its only importer in the repository was its own test, so no consumer following the barrel could call it.",
|
|
24
|
+
"text": "`bake/hlod.ts` builds the proxies and impostors `docs/CAPABILITIES.md` lists as shipping. It is tested, it is sized, it carries the licence, and until now nothing outside its own test imported it — so a consumer following this engine's own rule that the barrel is the contract could read the capability map, believe the row, and find no way to call it.\n\nThe assets barrel already carries that paragraph twice, for the cluster bakers and for the object SDF. What is new is that a demo boundary test caught those two and nothing caught this one, because no scene tries to bake an impostor. **A module reached only by its own test is not shipped**, and no gate here asks.\n\nFound by censusing what three consumers could adopt and what they could not."
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
"kind": "fixed",
|
|
28
|
+
"short": "A device that refuses only the transparent pipeline draws the city again. Skipping that pass took its clear with it, and the resolve then composited an untouched target over the whole frame.",
|
|
29
|
+
"text": "**Reported from a Galaxy S23 Ultra and then an Adreno 740, and 4.1.2 caused it.** Both refuse `gpu-driven blend` with `VK_ERROR_UNKNOWN` and nothing else, and both drew the whole city and showed black: the page's own readout said 43 fps, 1,087 clusters drawn and 27,167 in the sun's map while the screen was empty.\n\n**The blend pass is what clears the two weighted-transparency targets** — accumulation to zero and reveal to one, meaning nothing has covered this pixel yet. 4.1.2 skipped that pass where the device refused its pipeline, which was right, and took the clear with it, which was not. The resolve was left running against targets nothing had written, and a reveal of zero is *fully covered*, over every pixel, so it composited the opaque image away.\n\n**The comment beside that clear had already predicted it**, in as many words: cleared to zero \"the resolve would show no scene anywhere the pass ran, which is a black frame that looks like the blend working\". It was describing the clear value, and it turned out to describe the missing clear too.\n\nThe resolve now skips on the same verdict as the draw. There is nothing to composite when nothing was rasterised, and the opaque image is already in place before the resolve would load it.\n\n**What is still not fixed is why those drivers refuse the pipeline.** Its targets are `rgba16float` and `r8unorm` under `layout: auto`, and diagnosing `VK_ERROR_UNKNOWN` needs the part that produces it. This is the containment working as intended: a refusal costs the glass it is about."
|
|
30
|
+
}
|
|
31
|
+
]
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
"version": "4.1.3",
|
|
35
|
+
"date": "2026-09-20",
|
|
36
|
+
"entries": [
|
|
37
|
+
{
|
|
38
|
+
"kind": "fixed",
|
|
39
|
+
"short": "An imported model draws as it did before 4.0.0. Decoding images with straight alpha let the bytes under a transparent texel reach the frame, and on an opaque draw nothing discards them.",
|
|
40
|
+
"text": "**Reported as a car whose interior, grille, mirrors and lamps came through as hard black and white shards, where the published build of the same game was correct.** 4.0.0 changed how a `.drft` decodes its images, from premultiplied to straight alpha.\n\n**The argument for straight alpha was measured and is still true.** `createImageBitmap` premultiplies unless told not to, the upload into a straight-alpha texture divides back out, and on a PNG holding every colour at every alpha, 98,463 of 196,608 channel values came back changed. None did with `premultiplyAlpha: none`. Every cutout and emblem was losing the colour its author padded past its edge.\n\n**What it did not measure is content.** An imported material routinely carries arbitrary bytes under its fully transparent texels, and premultiplying is what kept them out of the frame. Straight alpha let them through, and an opaque draw discards nothing, so the shader takes the colour whatever the alpha beside it says.\n\n**Isolated in the showroom on held frames**, which is the part worth keeping. A model with 41 images drew clean and two with 64 and 65 drew shards, on both backends, with the old container and the new alike, fully streamed. The count only decides how likely a model is to carry a masked texture at all. Restoring the one option drew the car correctly.\n\nSo the faithful choice per texel was the wrong choice per model. `colorSpaceConversion` is unchanged and stays off."
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
"kind": "fixed",
|
|
44
|
+
"short": "A tested material stating a zero alpha reference takes the shader default rather than discarding nothing. A grille's holes and a seat's stitching were coming through filled in.",
|
|
45
|
+
"text": "**`ksAlphaRef` of zero means the shader's own threshold, not discard nothing.** The `.kn5` reader read it literally, and the first car to set the flag set it on eleven materials with a reference of zero on every one: `int_net`, `int_stitching`, `grille_a`, `hood_labels`. A threshold of zero discards nothing, so a grille's holes and a seat's stitching were drawn solid.\n\nA material that says it is alpha tested has already said something is meant to go, so a reference of zero cannot be read literally without contradicting the flag beside it. Half, because that is the convention those shaders were authored against, and because these masks are two valued.\n\nThe comment this replaces said the path was carried on the format's word rather than on evidence. This is the evidence arriving."
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
"kind": "fixed",
|
|
49
|
+
"short": "A device that refuses the transparent pipeline now really does lose only the glass. The guard added in 4.1.2 warned correctly and did not prevent a single one of the errors it was written for.",
|
|
50
|
+
"text": "**4.1.2 had this the wrong way round, and the reporter proved it on the next run.** That version built the pipeline, bound a group against its layout, encoded the pass, and disowned the handle when the error scope came back. A scope comes back on a microtask, after all three. The log carried the new warning and then every error it was meant to prevent: `GetBindGroupLayout` on an invalid pipeline, a bind group against an invalid layout, a `SetPipeline` with it, and a refused submit.\n\n**Nothing touches the handle until the device has answered now.** The bindings are held as a closure rather than built, and run once, either where the verdict has already landed or when it does. What that costs is no transparency for the frames before the answer, which nobody sees.\n\n**A device offering no error scopes is taken at its word**, because refusing transparency forever on hardware that never said no would be the worse failure of the two."
|
|
51
|
+
}
|
|
52
|
+
]
|
|
53
|
+
},
|
|
2
54
|
{
|
|
3
55
|
"version": "4.1.2",
|
|
4
56
|
"date": "2026-09-20",
|
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
The runtime everything needs: renderer, loop, camera, input, geometry. Collision and dynamics live in [`@driftengine/physics`](../physics/README.md), which this package depends on and re-exports whole, so reaching for them through this barrel works as it always did.
|
|
4
4
|
|
|
5
|
-
**Cost:
|
|
5
|
+
**Cost: 721.2 KB gzipped, importing `createRenderer`.** Measured by `scripts/size-gate.test.mjs`, which fails if it drifts more than
|
|
6
6
|
3% — the number is a fact about the build rather than a claim in a document.
|
|
7
7
|
|
|
8
8
|
Part of [DriftEngine](../../README.md). See [`docs/ARCHITECTURE.md`](../../docs/ARCHITECTURE.md)
|
package/dist/index.js
CHANGED
|
@@ -102,8 +102,18 @@ export { createRenderer } from './render/backend/createRenderer.js';
|
|
|
102
102
|
* A consumer who names neither pays nothing: `core-only` is measured with these exported and does
|
|
103
103
|
* not move, because nothing in the forward path reaches them.
|
|
104
104
|
*
|
|
105
|
-
* It draws vertex colour, one directional term, a hemispheric ambient and an emissive add — not
|
|
106
|
-
* the standard material
|
|
105
|
+
* **It draws vertex colour, one directional term, a hemispheric ambient and an emissive add — not
|
|
106
|
+
* the standard material**, and that is the sentence to read before reaching for it. There is no
|
|
107
|
+
* flag that routes `drawMesh` through this: a consumer packs a scene and contributes the pass, and
|
|
108
|
+
* `setMaterial`, `setSurfaceGrain`, `setSurfaceRelief` and a skin palette reach none of it. What
|
|
109
|
+
* would change that is a second copy of the standard material, which `gpuDrivenPass.ts` refuses in
|
|
110
|
+
* its own header.
|
|
111
|
+
*
|
|
112
|
+
* **The line that used to be here said `DRAFT_SCENES`, never `SCENES`, and it went stale the day
|
|
113
|
+
* the city was published.** `city` is in `SCENES` and says `pipelines: ['gpu-driven']`, so a host
|
|
114
|
+
* that cannot run the second pipeline reads that from the list rather than from a mount that
|
|
115
|
+
* refuses. The rule it was standing in for is the one above: what gates a scene is whether this
|
|
116
|
+
* pass can draw it, not which array it is in.
|
|
107
117
|
*/
|
|
108
118
|
export { GPU_DRIVEN_LISTS, GpuDrivenPass, MATERIAL_FLOATS,
|
|
109
119
|
/*
|
|
@@ -456,6 +456,46 @@ export declare class GpuDrivenPass implements PassDefinition {
|
|
|
456
456
|
private oitAccumView;
|
|
457
457
|
private oitRevealView;
|
|
458
458
|
private blendPipeline;
|
|
459
|
+
/**
|
|
460
|
+
* Whether the device has actually accepted the blend pipeline.
|
|
461
|
+
*
|
|
462
|
+
* **False until it says so, and 4.1.2 had this the wrong way round.** That version built the
|
|
463
|
+
* pipeline, bound a group against its layout, encoded the pass, and disowned the handle when the
|
|
464
|
+
* error scope came back — but a scope comes back on a microtask, after all three. The reporter's
|
|
465
|
+
* next run carried the new warning *and* every error it was meant to prevent: `GetBindGroupLayout`
|
|
466
|
+
* on an invalid pipeline, a bind group against an invalid layout, a `SetPipeline` with it, and a
|
|
467
|
+
* refused submit.
|
|
468
|
+
*
|
|
469
|
+
* Nothing touches the handle until this is true. The cost is no transparency for the frames
|
|
470
|
+
* before the device answers, which nobody sees; the alternative is a frame that draws nothing.
|
|
471
|
+
*/
|
|
472
|
+
private blendUsable;
|
|
473
|
+
/**
|
|
474
|
+
* The same question about *every other* pipeline in this pass, and the reason it exists is that
|
|
475
|
+
* 4.1.3 asked it about one.
|
|
476
|
+
*
|
|
477
|
+
* **The guard above was written from a log that named `gpu-driven blend`, and the four render
|
|
478
|
+
* pipelines and eight compute pipelines beside it were left with the shape that caused the
|
|
479
|
+
* report.** Reported again from an Android handset once that fix shipped: the transparent pass
|
|
480
|
+
* now skips itself correctly, the overlay still draws and the frame counter still counts — and
|
|
481
|
+
* the world is black, which is what an invalid *opaque* pipeline looks like once the blended one
|
|
482
|
+
* has stopped taking the command buffer with it.
|
|
483
|
+
*
|
|
484
|
+
* So the whole of `buildPipelines` is built inside one pair of scopes and nothing this pass
|
|
485
|
+
* encodes runs until they come back clean. The blend pipeline pushes its own pair *inside* that
|
|
486
|
+
* region, so the two verdicts stay separate: a device that refuses only the transparent pipeline
|
|
487
|
+
* still draws everything opaque, and a device that refuses anything else turns this pass off
|
|
488
|
+
* rather than encoding against a handle the driver has already rejected.
|
|
489
|
+
*
|
|
490
|
+
* **What it gives up** is the first frames, which draw nothing while the device is still
|
|
491
|
+
* answering. **What would make it wrong** is a device that never resolves an error scope; the
|
|
492
|
+
* `popErrorScope` check below is the same concession the blend guard makes, for the same reason.
|
|
493
|
+
*/
|
|
494
|
+
private usable;
|
|
495
|
+
/** Why this pass refused itself, in the words the device used, or null while it has not. */
|
|
496
|
+
private refusal;
|
|
497
|
+
/** Builds the transparent raster's bindings, held until there is a pipeline worth binding to. */
|
|
498
|
+
private makeBlendGroup;
|
|
459
499
|
private blendResolvePipeline;
|
|
460
500
|
private blendLodGroup;
|
|
461
501
|
private blendCompactGroup;
|
|
@@ -625,6 +665,28 @@ export declare class GpuDrivenPass implements PassDefinition {
|
|
|
625
665
|
* this came from was five errors that were all the same one.
|
|
626
666
|
*/
|
|
627
667
|
private watchBlendPipeline;
|
|
668
|
+
/**
|
|
669
|
+
* Close the scopes every other pipeline was built in, and refuse the pass where one came back.
|
|
670
|
+
*
|
|
671
|
+
* **This is `watchBlendPipeline` widened to the rest of the pass**, and the reason it is separate
|
|
672
|
+
* is that the two refusals mean different things: a blend pipeline nobody can compile costs the
|
|
673
|
+
* glass, and a raster, shadow, blit or compute pipeline nobody can compile costs the world. The
|
|
674
|
+
* first degrades; the second has nothing to degrade to, so it says so and draws nothing rather
|
|
675
|
+
* than encoding a command buffer the driver will reject whole.
|
|
676
|
+
*
|
|
677
|
+
* A device with no scopes is taken at its word, exactly as the blend guard takes it: refusing to
|
|
678
|
+
* draw on hardware that never said no is the worse of the two failures.
|
|
679
|
+
*/
|
|
680
|
+
private watchPipelines;
|
|
681
|
+
/**
|
|
682
|
+
* Why this pass is drawing nothing, in the words the device used, or null where it is not.
|
|
683
|
+
*
|
|
684
|
+
* **Exposed because a black frame that says nothing is the bug this was reported as, twice.** A
|
|
685
|
+
* pipeline refusal arrives asynchronously, long after `createRenderer` has decided the backend
|
|
686
|
+
* can run this at all, so there is no earlier place to throw — and a scene that can read this can
|
|
687
|
+
* tell somebody what happened instead of presenting an empty picture.
|
|
688
|
+
*/
|
|
689
|
+
refusedBecause(): string | null;
|
|
628
690
|
private buildPipelines;
|
|
629
691
|
/**
|
|
630
692
|
* The groups that name `history` and `drawn`, rebuilt when the two swap.
|
|
@@ -431,6 +431,46 @@ export class GpuDrivenPass {
|
|
|
431
431
|
oitAccumView = null;
|
|
432
432
|
oitRevealView = null;
|
|
433
433
|
blendPipeline = null;
|
|
434
|
+
/**
|
|
435
|
+
* Whether the device has actually accepted the blend pipeline.
|
|
436
|
+
*
|
|
437
|
+
* **False until it says so, and 4.1.2 had this the wrong way round.** That version built the
|
|
438
|
+
* pipeline, bound a group against its layout, encoded the pass, and disowned the handle when the
|
|
439
|
+
* error scope came back — but a scope comes back on a microtask, after all three. The reporter's
|
|
440
|
+
* next run carried the new warning *and* every error it was meant to prevent: `GetBindGroupLayout`
|
|
441
|
+
* on an invalid pipeline, a bind group against an invalid layout, a `SetPipeline` with it, and a
|
|
442
|
+
* refused submit.
|
|
443
|
+
*
|
|
444
|
+
* Nothing touches the handle until this is true. The cost is no transparency for the frames
|
|
445
|
+
* before the device answers, which nobody sees; the alternative is a frame that draws nothing.
|
|
446
|
+
*/
|
|
447
|
+
blendUsable = false;
|
|
448
|
+
/**
|
|
449
|
+
* The same question about *every other* pipeline in this pass, and the reason it exists is that
|
|
450
|
+
* 4.1.3 asked it about one.
|
|
451
|
+
*
|
|
452
|
+
* **The guard above was written from a log that named `gpu-driven blend`, and the four render
|
|
453
|
+
* pipelines and eight compute pipelines beside it were left with the shape that caused the
|
|
454
|
+
* report.** Reported again from an Android handset once that fix shipped: the transparent pass
|
|
455
|
+
* now skips itself correctly, the overlay still draws and the frame counter still counts — and
|
|
456
|
+
* the world is black, which is what an invalid *opaque* pipeline looks like once the blended one
|
|
457
|
+
* has stopped taking the command buffer with it.
|
|
458
|
+
*
|
|
459
|
+
* So the whole of `buildPipelines` is built inside one pair of scopes and nothing this pass
|
|
460
|
+
* encodes runs until they come back clean. The blend pipeline pushes its own pair *inside* that
|
|
461
|
+
* region, so the two verdicts stay separate: a device that refuses only the transparent pipeline
|
|
462
|
+
* still draws everything opaque, and a device that refuses anything else turns this pass off
|
|
463
|
+
* rather than encoding against a handle the driver has already rejected.
|
|
464
|
+
*
|
|
465
|
+
* **What it gives up** is the first frames, which draw nothing while the device is still
|
|
466
|
+
* answering. **What would make it wrong** is a device that never resolves an error scope; the
|
|
467
|
+
* `popErrorScope` check below is the same concession the blend guard makes, for the same reason.
|
|
468
|
+
*/
|
|
469
|
+
usable = false;
|
|
470
|
+
/** Why this pass refused itself, in the words the device used, or null while it has not. */
|
|
471
|
+
refusal = null;
|
|
472
|
+
/** Builds the transparent raster's bindings, held until there is a pipeline worth binding to. */
|
|
473
|
+
makeBlendGroup = null;
|
|
434
474
|
blendResolvePipeline = null;
|
|
435
475
|
blendLodGroup = null;
|
|
436
476
|
blendCompactGroup = null;
|
|
@@ -671,7 +711,18 @@ export class GpuDrivenPass {
|
|
|
671
711
|
});
|
|
672
712
|
}
|
|
673
713
|
this.buildBuffers(device.device);
|
|
714
|
+
/*
|
|
715
|
+
* **Every pipeline this pass owns, inside one pair of scopes.** See `usable`: the blend
|
|
716
|
+
* pipeline pushes its own pair within this region, so its refusal stays its own and any other
|
|
717
|
+
* refusal is caught here and turns the pass off instead of reaching an encoder.
|
|
718
|
+
*/
|
|
719
|
+
const scoped = typeof device.device.pushErrorScope === 'function';
|
|
720
|
+
if (scoped) {
|
|
721
|
+
device.device.pushErrorScope('validation');
|
|
722
|
+
device.device.pushErrorScope('internal');
|
|
723
|
+
}
|
|
674
724
|
this.buildPipelines(device.device, device.format, device.samples);
|
|
725
|
+
this.watchPipelines(device.device, scoped);
|
|
675
726
|
if (this.width > 0)
|
|
676
727
|
this.buildTargets(device.device);
|
|
677
728
|
}
|
|
@@ -953,16 +1004,80 @@ export class GpuDrivenPass {
|
|
|
953
1004
|
* this came from was five errors that were all the same one.
|
|
954
1005
|
*/
|
|
955
1006
|
watchBlendPipeline(device) {
|
|
956
|
-
|
|
957
|
-
|
|
1007
|
+
/*
|
|
1008
|
+
* **A device with no error scopes is taken at its word.** Nothing can be asked of it, and
|
|
1009
|
+
* refusing transparency forever on a device that never said no would be a worse failure than
|
|
1010
|
+
* the one this guards: a frame that quietly loses its glass on hardware that could draw it.
|
|
1011
|
+
*/
|
|
1012
|
+
if (typeof device.popErrorScope !== 'function') {
|
|
1013
|
+
this.blendUsable = true;
|
|
1014
|
+
if (this.makeBlendGroup !== null)
|
|
1015
|
+
this.blendRasterGroup = this.makeBlendGroup();
|
|
1016
|
+
return;
|
|
1017
|
+
}
|
|
1018
|
+
let outstanding = 2;
|
|
1019
|
+
const settle = (error, scope) => {
|
|
1020
|
+
outstanding -= 1;
|
|
1021
|
+
if (error !== null && this.blendPipeline !== null) {
|
|
1022
|
+
this.blendPipeline = null;
|
|
1023
|
+
this.blendRasterGroup = null;
|
|
1024
|
+
this.makeBlendGroup = null;
|
|
1025
|
+
console.warn(`[driftengine] this device refused the gpu-driven blend pipeline (${scope}), so the ` +
|
|
1026
|
+
`transparent pass is off and everything opaque still draws: ${error.message}`);
|
|
1027
|
+
}
|
|
1028
|
+
/* Usable only once both scopes have come back clean, and only then is anything bound. */
|
|
1029
|
+
if (outstanding > 0 || this.blendPipeline === null)
|
|
958
1030
|
return;
|
|
959
|
-
this.
|
|
960
|
-
this.
|
|
961
|
-
|
|
962
|
-
`transparent pass is off and everything opaque still draws: ${error.message}`);
|
|
1031
|
+
this.blendUsable = true;
|
|
1032
|
+
if (this.makeBlendGroup !== null)
|
|
1033
|
+
this.blendRasterGroup = this.makeBlendGroup();
|
|
963
1034
|
};
|
|
964
|
-
void device.popErrorScope().then((error) =>
|
|
965
|
-
void device.popErrorScope().then((error) =>
|
|
1035
|
+
void device.popErrorScope().then((error) => settle(error, 'internal'));
|
|
1036
|
+
void device.popErrorScope().then((error) => settle(error, 'validation'));
|
|
1037
|
+
}
|
|
1038
|
+
/**
|
|
1039
|
+
* Close the scopes every other pipeline was built in, and refuse the pass where one came back.
|
|
1040
|
+
*
|
|
1041
|
+
* **This is `watchBlendPipeline` widened to the rest of the pass**, and the reason it is separate
|
|
1042
|
+
* is that the two refusals mean different things: a blend pipeline nobody can compile costs the
|
|
1043
|
+
* glass, and a raster, shadow, blit or compute pipeline nobody can compile costs the world. The
|
|
1044
|
+
* first degrades; the second has nothing to degrade to, so it says so and draws nothing rather
|
|
1045
|
+
* than encoding a command buffer the driver will reject whole.
|
|
1046
|
+
*
|
|
1047
|
+
* A device with no scopes is taken at its word, exactly as the blend guard takes it: refusing to
|
|
1048
|
+
* draw on hardware that never said no is the worse of the two failures.
|
|
1049
|
+
*/
|
|
1050
|
+
watchPipelines(device, scoped) {
|
|
1051
|
+
if (!scoped || typeof device.popErrorScope !== 'function') {
|
|
1052
|
+
this.usable = true;
|
|
1053
|
+
return;
|
|
1054
|
+
}
|
|
1055
|
+
let outstanding = 2;
|
|
1056
|
+
const settle = (error, scope) => {
|
|
1057
|
+
outstanding -= 1;
|
|
1058
|
+
if (error !== null && this.refusal === null) {
|
|
1059
|
+
this.refusal = error.message;
|
|
1060
|
+
console.warn(`[driftengine] this device refused a gpu-driven pipeline (${scope}), so this pass draws ` +
|
|
1061
|
+
`nothing and the rest of the frame is unaffected: ${error.message}`);
|
|
1062
|
+
}
|
|
1063
|
+
/* Usable only once both have come back clean, which is what keeps an invalid handle out. */
|
|
1064
|
+
if (outstanding > 0 || this.refusal !== null)
|
|
1065
|
+
return;
|
|
1066
|
+
this.usable = true;
|
|
1067
|
+
};
|
|
1068
|
+
void device.popErrorScope().then((error) => settle(error, 'internal'));
|
|
1069
|
+
void device.popErrorScope().then((error) => settle(error, 'validation'));
|
|
1070
|
+
}
|
|
1071
|
+
/**
|
|
1072
|
+
* Why this pass is drawing nothing, in the words the device used, or null where it is not.
|
|
1073
|
+
*
|
|
1074
|
+
* **Exposed because a black frame that says nothing is the bug this was reported as, twice.** A
|
|
1075
|
+
* pipeline refusal arrives asynchronously, long after `createRenderer` has decided the backend
|
|
1076
|
+
* can run this at all, so there is no earlier place to throw — and a scene that can read this can
|
|
1077
|
+
* tell somebody what happened instead of presenting an empty picture.
|
|
1078
|
+
*/
|
|
1079
|
+
refusedBecause() {
|
|
1080
|
+
return this.refusal;
|
|
966
1081
|
}
|
|
967
1082
|
buildPipelines(device, format, samples) {
|
|
968
1083
|
const compute = (label, code) => device.createComputePipeline({
|
|
@@ -1083,8 +1198,11 @@ export class GpuDrivenPass {
|
|
|
1083
1198
|
* backends and as validation on others, and this is exactly the case where guessing which
|
|
1084
1199
|
* costs the whole frame.
|
|
1085
1200
|
*/
|
|
1086
|
-
device.
|
|
1087
|
-
device.pushErrorScope
|
|
1201
|
+
/* Only where the device offers them: see `watchBlendPipeline` for the case where it does not. */
|
|
1202
|
+
if (typeof device.pushErrorScope === 'function') {
|
|
1203
|
+
device.pushErrorScope('validation');
|
|
1204
|
+
device.pushErrorScope('internal');
|
|
1205
|
+
}
|
|
1088
1206
|
this.blendPipeline = device.createRenderPipeline({
|
|
1089
1207
|
label: 'gpu-driven blend',
|
|
1090
1208
|
layout: 'auto',
|
|
@@ -1627,41 +1745,44 @@ export class GpuDrivenPass {
|
|
|
1627
1745
|
});
|
|
1628
1746
|
/* Everything the transparent raster reads: the same scene, the same frame, its own list. */
|
|
1629
1747
|
/*
|
|
1630
|
-
*
|
|
1631
|
-
*
|
|
1632
|
-
*
|
|
1633
|
-
* cascade this guard exists to stop.
|
|
1748
|
+
* Held rather than built, because whether the pipeline is usable is not known yet: the error
|
|
1749
|
+
* scopes settle on a microtask and this runs synchronously. `watchBlendPipeline` runs it once
|
|
1750
|
+
* the device has answered, and never if the answer was no.
|
|
1634
1751
|
*/
|
|
1635
|
-
this.
|
|
1636
|
-
this.blendPipeline
|
|
1637
|
-
|
|
1638
|
-
:
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
|
|
1650
|
-
|
|
1651
|
-
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
|
|
1657
|
-
|
|
1658
|
-
|
|
1659
|
-
|
|
1660
|
-
|
|
1661
|
-
|
|
1662
|
-
|
|
1663
|
-
|
|
1664
|
-
|
|
1752
|
+
this.makeBlendGroup = () => device.createBindGroup({
|
|
1753
|
+
layout: this.blendPipeline.getBindGroupLayout(0),
|
|
1754
|
+
entries: [
|
|
1755
|
+
{ binding: 0, resource: { buffer: this.viewProjBuffer } },
|
|
1756
|
+
{ binding: 1, resource: { buffer: this.blendList } },
|
|
1757
|
+
{ binding: 2, resource: { buffer: this.clusterMeta } },
|
|
1758
|
+
{ binding: 3, resource: { buffer: this.indices } },
|
|
1759
|
+
{ binding: 4, resource: { buffer: this.vertices } },
|
|
1760
|
+
{ binding: 5, resource: { buffer: this.transformBuffer } },
|
|
1761
|
+
{ binding: 6, resource: { buffer: this.materialOf } },
|
|
1762
|
+
{ binding: 7, resource: { buffer: this.materialTable } },
|
|
1763
|
+
{ binding: 8, resource: { buffer: this.frameBuffer } },
|
|
1764
|
+
{
|
|
1765
|
+
binding: 9,
|
|
1766
|
+
resource: this.environmentView ?? this.emptyEnvironmentView,
|
|
1767
|
+
},
|
|
1768
|
+
{
|
|
1769
|
+
binding: 10,
|
|
1770
|
+
resource: this.environmentSampler ?? this.emptySampler,
|
|
1771
|
+
},
|
|
1772
|
+
{ binding: 11, resource: this.latentsView },
|
|
1773
|
+
{ binding: 12, resource: this.latentClamp },
|
|
1774
|
+
{ binding: 13, resource: this.latentRepeat },
|
|
1775
|
+
{ binding: 14, resource: { buffer: this.decodeNodes } },
|
|
1776
|
+
{ binding: 15, resource: { buffer: this.decodeWeights } },
|
|
1777
|
+
],
|
|
1778
|
+
});
|
|
1779
|
+
/*
|
|
1780
|
+
* And build it now where the device has already answered, which is the ordinary case: the
|
|
1781
|
+
* verdict lands before this runs on a device with no error scopes, and after it on one that
|
|
1782
|
+
* has them. Whichever order, exactly one of the two places builds the group.
|
|
1783
|
+
*/
|
|
1784
|
+
if (this.blendUsable)
|
|
1785
|
+
this.blendRasterGroup = this.makeBlendGroup();
|
|
1665
1786
|
this.blendResolveGroup = device.createBindGroup({
|
|
1666
1787
|
layout: this.blendResolvePipeline.getBindGroupLayout(0),
|
|
1667
1788
|
entries: [
|
|
@@ -1893,6 +2014,9 @@ export class GpuDrivenPass {
|
|
|
1893
2014
|
prepare(ctx) {
|
|
1894
2015
|
if (ctx.backend !== 'webgpu')
|
|
1895
2016
|
return;
|
|
2017
|
+
/* Nothing is encoded until the device has answered for the pipelines. See `usable`. */
|
|
2018
|
+
if (!this.usable)
|
|
2019
|
+
return;
|
|
1896
2020
|
const device = this.device;
|
|
1897
2021
|
if (device === null || this.levels.length === 0)
|
|
1898
2022
|
return;
|
|
@@ -2086,7 +2210,7 @@ export class GpuDrivenPass {
|
|
|
2086
2210
|
* Everything opaque has already been drawn by the time this runs, so what is lost is the
|
|
2087
2211
|
* glass and not the city. See `watchBlendPipeline`.
|
|
2088
2212
|
*/
|
|
2089
|
-
if (this.blendPipeline === null || this.blendRasterGroup === null)
|
|
2213
|
+
if (!this.blendUsable || this.blendPipeline === null || this.blendRasterGroup === null)
|
|
2090
2214
|
return;
|
|
2091
2215
|
const pass = encoder.beginRenderPass({
|
|
2092
2216
|
label: 'gpu-driven blend draw',
|
|
@@ -2123,6 +2247,26 @@ export class GpuDrivenPass {
|
|
|
2123
2247
|
return;
|
|
2124
2248
|
}
|
|
2125
2249
|
case 'blendResolve': {
|
|
2250
|
+
/*
|
|
2251
|
+
* **Skipped on the same verdict as the draw, because the draw is what clears what this
|
|
2252
|
+
* reads.** This is the whole of the reported black frame, and the line above it predicted
|
|
2253
|
+
* it: the blend pass clears reveal to one, meaning nothing has covered this pixel yet, and
|
|
2254
|
+
* says in as many words that cleared to zero "the resolve would show no scene anywhere the
|
|
2255
|
+
* pass ran, which is a black frame that looks like the blend working".
|
|
2256
|
+
*
|
|
2257
|
+
* 4.1.2 skipped the draw and took its clear with it, and left this running against textures
|
|
2258
|
+
* nothing had written — a reveal of zero over every pixel, which is exactly "fully covered".
|
|
2259
|
+
* Reported from a Galaxy S23 Ultra and then an Adreno 740, both of which refuse only
|
|
2260
|
+
* `gpu-driven blend` and both of which drew the city into a buffer this then wiped: the
|
|
2261
|
+
* page's own readout said 43 fps and 1,087 clusters drawn while the screen was black.
|
|
2262
|
+
*
|
|
2263
|
+
* There is nothing to composite when nothing was rasterised, so the honest answer is not to
|
|
2264
|
+
* composite. **What would make it wrong** is a caller that expects this pass to have run for
|
|
2265
|
+
* some other reason; nothing does, and the opaque image is already in `colourView` before
|
|
2266
|
+
* this would load it.
|
|
2267
|
+
*/
|
|
2268
|
+
if (!this.blendUsable)
|
|
2269
|
+
return;
|
|
2126
2270
|
const pass = encoder.beginRenderPass({
|
|
2127
2271
|
label: 'gpu-driven blend resolve',
|
|
2128
2272
|
colorAttachments: [
|
|
@@ -2426,6 +2570,9 @@ export class GpuDrivenPass {
|
|
|
2426
2570
|
draw(ctx) {
|
|
2427
2571
|
if (ctx.backend !== 'webgpu')
|
|
2428
2572
|
return;
|
|
2573
|
+
/* The same gate as `prepare`: an unanswered or refused pipeline set encodes nothing at all. */
|
|
2574
|
+
if (!this.usable)
|
|
2575
|
+
return;
|
|
2429
2576
|
if (this.blitPipeline === null || this.blitGroup === null)
|
|
2430
2577
|
return;
|
|
2431
2578
|
if (ctx.outputTransform !== 0 && !this.gradeWarned) {
|
|
@@ -38,6 +38,7 @@ import { POINT_SHADOW_SAMPLER, PointShadowSystem, } from '../../pointShadowSyste
|
|
|
38
38
|
import { GpuPointShadowArray, GpuPointShadowMap } from './pointShadowPass.js';
|
|
39
39
|
import { createPanelBindGroup, createPanelBindGroupLayout, createPanelCorners, panelPipeline, PANEL_FRAG_FIELDS, PANEL_FRAG_SIZE, PANEL_VERTEX_COUNT, PANEL_VERT_FIELDS, PANEL_VERT_SIZE, } from './panelPass.js';
|
|
40
40
|
import { createGpuTextCube, createTextBindGroup, createTextBindGroupLayout, GpuText, textPipeline, TEXT_FRAG_FIELDS, TEXT_FRAG_SIZE, TEXT_VERT_FIELDS, TEXT_VERT_SIZE, } from './textPass.js';
|
|
41
|
+
import { deviceSnappedCellSize, deviceSnappedOrigin } from '../../textLayout.js';
|
|
41
42
|
import { GpuSurfaceTexture } from './surfaceTexturePass.js';
|
|
42
43
|
import { createSdfTextBindGroup, createSdfTextBindGroupLayout, GpuSdfText, sdfTextPipeline, SDF_TEXT_FRAG_FIELDS, SDF_TEXT_FRAG_SIZE, SDF_TEXT_VERT_FIELDS, SDF_TEXT_VERT_SIZE, } from './sdfTextPass.js';
|
|
43
44
|
import { createFilmBindGroup, createFilmBindGroupLayout, filmPipeline, FILM_FRAG_FIELDS, FILM_FRAG_SIZE, FILM_VERT_FIELDS, FILM_VERT_SIZE, } from './filmPass.js';
|
|
@@ -2485,8 +2486,15 @@ export class WebGPURenderer {
|
|
|
2485
2486
|
const at = (name) => TEXT_VERT_FIELDS[name]?.offset ?? 0;
|
|
2486
2487
|
const v = this.textVerts;
|
|
2487
2488
|
v.writeFloats(vertexSlot, at('uViewport'), [viewportWidth, viewportHeight]);
|
|
2488
|
-
|
|
2489
|
-
|
|
2489
|
+
/* Snapped with the cell below: a whole pitch on a fractional origin still splits a stroke.
|
|
2490
|
+
See `deviceSnappedOrigin`. */
|
|
2491
|
+
v.writeFloats(vertexSlot, at('uOrigin'), [
|
|
2492
|
+
deviceSnappedOrigin(originX, viewportWidth, this.surface.canvas.width),
|
|
2493
|
+
deviceSnappedOrigin(originY, viewportHeight, this.surface.canvas.height),
|
|
2494
|
+
]);
|
|
2495
|
+
/* Whole device pixels per cell, or a 5x7 face draws strokes of two different widths. The
|
|
2496
|
+
decision is shared with WebGL2; only this binding is per-backend. See `textLayout.ts`. */
|
|
2497
|
+
v.writeFloat(vertexSlot, at('uCellSize'), deviceSnappedCellSize(style.cellSize, viewportWidth, this.surface.canvas.width));
|
|
2490
2498
|
/* Far enough that a whole line barely converges, near enough that a turn reads as a turn. */
|
|
2491
2499
|
v.writeFloat(vertexSlot, at('uDepth'), Math.max(viewportWidth, 600) * 1.4);
|
|
2492
2500
|
v.writeFloat(vertexSlot, at('uReveal'), style.reveal);
|