@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
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
|
/*
|
|
@@ -1066,6 +1066,21 @@ export declare class WebGL2Renderer implements RendererApi {
|
|
|
1066
1066
|
* near plane needs the granted answer and not the wished-for one.
|
|
1067
1067
|
*/
|
|
1068
1068
|
reversedDepth: boolean;
|
|
1069
|
+
/**
|
|
1070
|
+
* What to clear depth to, for the convention this context was actually granted.
|
|
1071
|
+
*
|
|
1072
|
+
* **`DEPTH_CLEAR` is compile-time and this must not be.** That constant is `REVERSED_DEPTH ? 0 :
|
|
1073
|
+
* 1`, and `REVERSED_DEPTH` is what the engine wants rather than what a context gave — so on a
|
|
1074
|
+
* context without `EXT_clip_control` the buffer was cleared to 0 while `depthFunc` was correctly
|
|
1075
|
+
* set to `LEQUAL`, and **nothing in the scene could ever pass**: every fragment is at depth >= 0,
|
|
1076
|
+
* and only depth <= 0 passes. The frame that reaches the screen is the colour clear.
|
|
1077
|
+
*
|
|
1078
|
+
* **Reported from Firefox on Linux, which exposes no `EXT_clip_control`**, as every consumer
|
|
1079
|
+
* drawing one flat colour with its interface still on top, at a healthy 60 fps. The compare
|
|
1080
|
+
* beside it was already runtime; this was the half that was not, and the asymmetry is what made
|
|
1081
|
+
* it total rather than subtle.
|
|
1082
|
+
*/
|
|
1083
|
+
private get depthClear();
|
|
1069
1084
|
/** `GL_DEPTH_REMAP * camera.viewProjection`, rebuilt once a frame. */
|
|
1070
1085
|
private readonly sceneViewProj;
|
|
1071
1086
|
/**
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { FrameBudget } from '../budget.js';
|
|
2
2
|
import { MaterialChanges, ownsMaterial } from '../materialChanges.js';
|
|
3
3
|
import { mat4 } from 'gl-matrix';
|
|
4
|
-
import {
|
|
4
|
+
import { DEPTH_OFFSET_SIGN, GL_DEPTH_REMAP, MAX_DEPTH_LAYER, depthClearFor, depthOffsetForLayer, GL_SHADOW_REMAP, REVERSED_DEPTH, SHADOW_DEPTH_CLEAR, glDepthFuncEqual, glShadowDepthFunc, } from '../../depthConvention.js';
|
|
5
5
|
import { PickableSet } from '../../pickable.js';
|
|
6
6
|
import { TEMPORAL_HISTORY_BLEND, TemporalHistory, jitterOffset, jitterProjection, } from '../../temporalAa.js';
|
|
7
7
|
import { DecalPass } from '../../decalPass.js';
|
|
@@ -45,7 +45,7 @@ import { packIesAtlas } from '../../iesProfile.js';
|
|
|
45
45
|
import { MAX_AREA_LIGHTS } from '../../areaLights.js';
|
|
46
46
|
import { areaShadowLayerCount, castingRange, createResolvedAreaShadows, firstAreaShadowLayer, AreaShadowSet, } from '../../areaShadowSet.js';
|
|
47
47
|
import { equirectToCubeFaces } from '../../equirectToCube.js';
|
|
48
|
-
import { SKY_FRAG,
|
|
48
|
+
import { SKY_FRAG, skyVertFor } from '../../shaders/sky.js';
|
|
49
49
|
import { DEPTH_FRAG, DEPTH_INSTANCED_VERT, DEPTH_SKINNED_VERT, DEPTH_VERT, } from '../../shaders/depth.js';
|
|
50
50
|
import { PointShadowArray } from '../../pointShadowArray.js';
|
|
51
51
|
import { ShadowMap } from '../../shadowMap.js';
|
|
@@ -749,6 +749,23 @@ export class WebGL2Renderer {
|
|
|
749
749
|
* near plane needs the granted answer and not the wished-for one.
|
|
750
750
|
*/
|
|
751
751
|
reversedDepth = false;
|
|
752
|
+
/**
|
|
753
|
+
* What to clear depth to, for the convention this context was actually granted.
|
|
754
|
+
*
|
|
755
|
+
* **`DEPTH_CLEAR` is compile-time and this must not be.** That constant is `REVERSED_DEPTH ? 0 :
|
|
756
|
+
* 1`, and `REVERSED_DEPTH` is what the engine wants rather than what a context gave — so on a
|
|
757
|
+
* context without `EXT_clip_control` the buffer was cleared to 0 while `depthFunc` was correctly
|
|
758
|
+
* set to `LEQUAL`, and **nothing in the scene could ever pass**: every fragment is at depth >= 0,
|
|
759
|
+
* and only depth <= 0 passes. The frame that reaches the screen is the colour clear.
|
|
760
|
+
*
|
|
761
|
+
* **Reported from Firefox on Linux, which exposes no `EXT_clip_control`**, as every consumer
|
|
762
|
+
* drawing one flat colour with its interface still on top, at a healthy 60 fps. The compare
|
|
763
|
+
* beside it was already runtime; this was the half that was not, and the asymmetry is what made
|
|
764
|
+
* it total rather than subtle.
|
|
765
|
+
*/
|
|
766
|
+
get depthClear() {
|
|
767
|
+
return depthClearFor(this.reversedDepth);
|
|
768
|
+
}
|
|
752
769
|
/** `GL_DEPTH_REMAP * camera.viewProjection`, rebuilt once a frame. */
|
|
753
770
|
sceneViewProj = new Float32Array(16);
|
|
754
771
|
/**
|
|
@@ -1745,7 +1762,10 @@ export class WebGL2Renderer {
|
|
|
1745
1762
|
}
|
|
1746
1763
|
}
|
|
1747
1764
|
this.filmUniforms = uniformLocations(gl, this.filmProgram, 'film');
|
|
1748
|
-
this
|
|
1765
|
+
/* The variant matching what this context was granted, not what the engine wants. See
|
|
1766
|
+
`skyVertFor`: a sky written at the reversed far plane on a conventional context sits in the
|
|
1767
|
+
middle of the depth range and paints over everything behind it. */
|
|
1768
|
+
this.skyProgram = compileProgram(gl, skyVertFor(this.reversedDepth), SKY_FRAG, 'sky');
|
|
1749
1769
|
this.skyUniforms = uniformLocations(gl, this.skyProgram, 'sky');
|
|
1750
1770
|
this.scatterProgram = compileProgram(gl, SCATTER_VERT, SCATTER_FRAG, 'scatter');
|
|
1751
1771
|
this.scatterUniforms = uniformLocations(gl, this.scatterProgram, 'scatter');
|
|
@@ -3139,12 +3159,13 @@ export class WebGL2Renderer {
|
|
|
3139
3159
|
gl.bindTexture(gl.TEXTURE_2D_ARRAY, this.pointShadowArray?.texture ?? this.emptyTexture2DArray);
|
|
3140
3160
|
gl.uniform1i(uniforms[POINT_SHADOW_SAMPLER] ?? null, firstUnit);
|
|
3141
3161
|
gl.uniform1iv(uniforms['uPointShadowLayer[0]'] ?? null, resolved.layers);
|
|
3142
|
-
|
|
3162
|
+
/* Origin and far plane in one row; see `uPointShadowProjection` in the preamble. */
|
|
3163
|
+
gl.uniform4fv(uniforms['uPointShadowProjection[0]'] ?? null, resolved.projections);
|
|
3143
3164
|
gl.uniform1fv(uniforms['uPointShadowNear[0]'] ?? null, resolved.near);
|
|
3144
3165
|
gl.uniform1fv(uniforms['uPointShadowSize[0]'] ?? null, resolved.sourceRadius);
|
|
3145
3166
|
gl.uniform1fv(uniforms['uPointShadowWeight[0]'] ?? null, resolved.presence);
|
|
3146
3167
|
gl.uniform1iv(uniforms['uLivePointShadowLayer[0]'] ?? null, resolved.liveLayers);
|
|
3147
|
-
gl.
|
|
3168
|
+
gl.uniform4fv(uniforms['uLivePointShadowProjection[0]'] ?? null, resolved.liveProjections);
|
|
3148
3169
|
gl.uniform1fv(uniforms['uLivePointShadowNear[0]'] ?? null, resolved.liveNear);
|
|
3149
3170
|
gl.uniform1fv(uniforms['uLivePointShadowSize[0]'] ?? null, resolved.liveSourceRadius);
|
|
3150
3171
|
gl.uniform1fv(uniforms['uLivePointShadowWeight[0]'] ?? null, resolved.liveWeights);
|
|
@@ -3573,7 +3594,7 @@ export class WebGL2Renderer {
|
|
|
3573
3594
|
shadowMap.end(this.gl);
|
|
3574
3595
|
/* Back to the frame's sense; see `beginShadowPass` for why it left it. */
|
|
3575
3596
|
this.gl.depthFunc(glDepthFuncEqual(this.gl));
|
|
3576
|
-
this.gl.clearDepth(
|
|
3597
|
+
this.gl.clearDepth(this.depthClear);
|
|
3577
3598
|
this.gpuTimer.end();
|
|
3578
3599
|
this.shadowPassActive = false;
|
|
3579
3600
|
this.activeShadowMap = null;
|
|
@@ -3949,7 +3970,7 @@ export class WebGL2Renderer {
|
|
|
3949
3970
|
finally {
|
|
3950
3971
|
/* Back to the frame's sense, which `beginShadowPass` leaves the same way. */
|
|
3951
3972
|
gl.depthFunc(glDepthFuncEqual(gl));
|
|
3952
|
-
gl.clearDepth(
|
|
3973
|
+
gl.clearDepth(this.depthClear);
|
|
3953
3974
|
this.pointShadowCasters = null;
|
|
3954
3975
|
this.depthPassCullsFaces = true;
|
|
3955
3976
|
}
|
|
@@ -470,6 +470,30 @@ export declare class GpuDrivenPass implements PassDefinition {
|
|
|
470
470
|
* before the device answers, which nobody sees; the alternative is a frame that draws nothing.
|
|
471
471
|
*/
|
|
472
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;
|
|
473
497
|
/** Builds the transparent raster's bindings, held until there is a pipeline worth binding to. */
|
|
474
498
|
private makeBlendGroup;
|
|
475
499
|
private blendResolvePipeline;
|
|
@@ -641,6 +665,28 @@ export declare class GpuDrivenPass implements PassDefinition {
|
|
|
641
665
|
* this came from was five errors that were all the same one.
|
|
642
666
|
*/
|
|
643
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;
|
|
644
690
|
private buildPipelines;
|
|
645
691
|
/**
|
|
646
692
|
* The groups that name `history` and `drawn`, rebuilt when the two swap.
|
|
@@ -445,6 +445,30 @@ export class GpuDrivenPass {
|
|
|
445
445
|
* before the device answers, which nobody sees; the alternative is a frame that draws nothing.
|
|
446
446
|
*/
|
|
447
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;
|
|
448
472
|
/** Builds the transparent raster's bindings, held until there is a pipeline worth binding to. */
|
|
449
473
|
makeBlendGroup = null;
|
|
450
474
|
blendResolvePipeline = null;
|
|
@@ -687,7 +711,18 @@ export class GpuDrivenPass {
|
|
|
687
711
|
});
|
|
688
712
|
}
|
|
689
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
|
+
}
|
|
690
724
|
this.buildPipelines(device.device, device.format, device.samples);
|
|
725
|
+
this.watchPipelines(device.device, scoped);
|
|
691
726
|
if (this.width > 0)
|
|
692
727
|
this.buildTargets(device.device);
|
|
693
728
|
}
|
|
@@ -1000,6 +1035,50 @@ export class GpuDrivenPass {
|
|
|
1000
1035
|
void device.popErrorScope().then((error) => settle(error, 'internal'));
|
|
1001
1036
|
void device.popErrorScope().then((error) => settle(error, 'validation'));
|
|
1002
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;
|
|
1081
|
+
}
|
|
1003
1082
|
buildPipelines(device, format, samples) {
|
|
1004
1083
|
const compute = (label, code) => device.createComputePipeline({
|
|
1005
1084
|
label: `gpu-driven ${label}`,
|
|
@@ -1935,6 +2014,9 @@ export class GpuDrivenPass {
|
|
|
1935
2014
|
prepare(ctx) {
|
|
1936
2015
|
if (ctx.backend !== 'webgpu')
|
|
1937
2016
|
return;
|
|
2017
|
+
/* Nothing is encoded until the device has answered for the pipelines. See `usable`. */
|
|
2018
|
+
if (!this.usable)
|
|
2019
|
+
return;
|
|
1938
2020
|
const device = this.device;
|
|
1939
2021
|
if (device === null || this.levels.length === 0)
|
|
1940
2022
|
return;
|
|
@@ -2165,6 +2247,26 @@ export class GpuDrivenPass {
|
|
|
2165
2247
|
return;
|
|
2166
2248
|
}
|
|
2167
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;
|
|
2168
2270
|
const pass = encoder.beginRenderPass({
|
|
2169
2271
|
label: 'gpu-driven blend resolve',
|
|
2170
2272
|
colorAttachments: [
|
|
@@ -2468,6 +2570,9 @@ export class GpuDrivenPass {
|
|
|
2468
2570
|
draw(ctx) {
|
|
2469
2571
|
if (ctx.backend !== 'webgpu')
|
|
2470
2572
|
return;
|
|
2573
|
+
/* The same gate as `prepare`: an unanswered or refused pipeline set encodes nothing at all. */
|
|
2574
|
+
if (!this.usable)
|
|
2575
|
+
return;
|
|
2471
2576
|
if (this.blitPipeline === null || this.blitGroup === null)
|
|
2472
2577
|
return;
|
|
2473
2578
|
if (ctx.outputTransform !== 0 && !this.gradeWarned) {
|
|
@@ -67,6 +67,10 @@ export declare class GpuPointShadowMap implements PointShadowSource {
|
|
|
67
67
|
get far(): number;
|
|
68
68
|
get near(): number;
|
|
69
69
|
get sourceRadius(): number;
|
|
70
|
+
/** Where the image was rendered from, not where the light is now. See the image. */
|
|
71
|
+
get originX(): number;
|
|
72
|
+
get originY(): number;
|
|
73
|
+
get originZ(): number;
|
|
70
74
|
get hasBaked(): boolean;
|
|
71
75
|
get presence(): number;
|
|
72
76
|
matchesSource(x: number, y: number, z: number, range: number, near: number, sourceRadius?: number, tolerance?: number): boolean;
|
|
@@ -229,6 +229,16 @@ export class GpuPointShadowMap {
|
|
|
229
229
|
get sourceRadius() {
|
|
230
230
|
return this.image.sourceRadius;
|
|
231
231
|
}
|
|
232
|
+
/** Where the image was rendered from, not where the light is now. See the image. */
|
|
233
|
+
get originX() {
|
|
234
|
+
return this.image.originX;
|
|
235
|
+
}
|
|
236
|
+
get originY() {
|
|
237
|
+
return this.image.originY;
|
|
238
|
+
}
|
|
239
|
+
get originZ() {
|
|
240
|
+
return this.image.originZ;
|
|
241
|
+
}
|
|
232
242
|
get hasBaked() {
|
|
233
243
|
return this.image.hasBaked;
|
|
234
244
|
}
|
|
@@ -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);
|
|
@@ -6225,11 +6233,16 @@ export class WebGPURenderer {
|
|
|
6225
6233
|
}
|
|
6226
6234
|
else {
|
|
6227
6235
|
this.writeShadowSelection(f, i, 'uPointShadow', resolved.layers, resolved.presence);
|
|
6228
|
-
|
|
6236
|
+
/*
|
|
6237
|
+
* A `vec4` row carrying the bake origin and the far plane, so `scatterInto` rather than
|
|
6238
|
+
* `writeShadowParams` — it reads the stride out of the generated layout instead of
|
|
6239
|
+
* counting rows, which is what AGENTS.md's 2026-09-20 alignment rule asks for.
|
|
6240
|
+
*/
|
|
6241
|
+
scatterInto(f, this.fragment.fields['uPointShadowProjection'], resolved.projections, 4);
|
|
6229
6242
|
this.writeShadowParams(f, 'uPointShadowNear', resolved.near);
|
|
6230
6243
|
this.writeShadowParams(f, 'uPointShadowSize', resolved.sourceRadius);
|
|
6231
6244
|
this.writeShadowSelection(f, i, 'uLivePointShadow', resolved.liveLayers, resolved.liveWeights);
|
|
6232
|
-
|
|
6245
|
+
scatterInto(f, this.fragment.fields['uLivePointShadowProjection'], resolved.liveProjections, 4);
|
|
6233
6246
|
this.writeShadowParams(f, 'uLivePointShadowNear', resolved.liveNear);
|
|
6234
6247
|
this.writeShadowParams(f, 'uLivePointShadowSize', resolved.liveSourceRadius);
|
|
6235
6248
|
}
|
|
@@ -101,6 +101,25 @@
|
|
|
101
101
|
export declare const REVERSED_DEPTH = true;
|
|
102
102
|
/** What a scene depth attachment is cleared to: the far plane. */
|
|
103
103
|
export declare const DEPTH_CLEAR: number;
|
|
104
|
+
/**
|
|
105
|
+
* The same value for a backend that learned its convention at runtime rather than at compile time.
|
|
106
|
+
*
|
|
107
|
+
* **`DEPTH_CLEAR` above says what the engine wants; this says what a context got.** Reversed depth
|
|
108
|
+
* on WebGL2 needs `EXT_clip_control`, and a context not granted it runs conventional depth while
|
|
109
|
+
* the constant still reads 0. Clearing to 0 with a `LEQUAL` compare rejects every fragment in the
|
|
110
|
+
* scene — every one sits at depth >= 0 and only depth <= 0 is admitted — so the frame that reaches
|
|
111
|
+
* the screen is the colour clear and nothing else.
|
|
112
|
+
*
|
|
113
|
+
* Reported from Firefox on Linux, which exposes no `EXT_clip_control`: every consumer drew one flat
|
|
114
|
+
* colour with its interface still on top, at a healthy sixty frames a second. The depth *compare*
|
|
115
|
+
* beside it was already chosen at runtime, and that asymmetry is what made this total rather than
|
|
116
|
+
* subtle — two halves of one convention would still have drawn a picture.
|
|
117
|
+
*
|
|
118
|
+
* **Here rather than in the backend** for the 2026-08-13 rule's first clause: the decision is
|
|
119
|
+
* backend-neutral and only the binding is per-backend. It is also what lets a test hold the clear
|
|
120
|
+
* and the compare to each other, which a private field on a renderer could not.
|
|
121
|
+
*/
|
|
122
|
+
export declare function depthClearFor(reversed: boolean): number;
|
|
104
123
|
/** The WebGPU compare for "nearer than what is there". */
|
|
105
124
|
export declare const DEPTH_COMPARE: GPUCompareFunction;
|
|
106
125
|
/**
|
|
@@ -101,6 +101,27 @@
|
|
|
101
101
|
export const REVERSED_DEPTH = true;
|
|
102
102
|
/** What a scene depth attachment is cleared to: the far plane. */
|
|
103
103
|
export const DEPTH_CLEAR = REVERSED_DEPTH ? 0 : 1;
|
|
104
|
+
/**
|
|
105
|
+
* The same value for a backend that learned its convention at runtime rather than at compile time.
|
|
106
|
+
*
|
|
107
|
+
* **`DEPTH_CLEAR` above says what the engine wants; this says what a context got.** Reversed depth
|
|
108
|
+
* on WebGL2 needs `EXT_clip_control`, and a context not granted it runs conventional depth while
|
|
109
|
+
* the constant still reads 0. Clearing to 0 with a `LEQUAL` compare rejects every fragment in the
|
|
110
|
+
* scene — every one sits at depth >= 0 and only depth <= 0 is admitted — so the frame that reaches
|
|
111
|
+
* the screen is the colour clear and nothing else.
|
|
112
|
+
*
|
|
113
|
+
* Reported from Firefox on Linux, which exposes no `EXT_clip_control`: every consumer drew one flat
|
|
114
|
+
* colour with its interface still on top, at a healthy sixty frames a second. The depth *compare*
|
|
115
|
+
* beside it was already chosen at runtime, and that asymmetry is what made this total rather than
|
|
116
|
+
* subtle — two halves of one convention would still have drawn a picture.
|
|
117
|
+
*
|
|
118
|
+
* **Here rather than in the backend** for the 2026-08-13 rule's first clause: the decision is
|
|
119
|
+
* backend-neutral and only the binding is per-backend. It is also what lets a test hold the clear
|
|
120
|
+
* and the compare to each other, which a private field on a renderer could not.
|
|
121
|
+
*/
|
|
122
|
+
export function depthClearFor(reversed) {
|
|
123
|
+
return reversed ? 0 : 1;
|
|
124
|
+
}
|
|
104
125
|
/** The WebGPU compare for "nearer than what is there". */
|
|
105
126
|
export const DEPTH_COMPARE = REVERSED_DEPTH ? 'greater' : 'less';
|
|
106
127
|
/**
|
|
@@ -155,6 +155,9 @@ export class LivePointShadowSet {
|
|
|
155
155
|
out.liveFar.fill(1);
|
|
156
156
|
out.liveNear.fill(0.01);
|
|
157
157
|
out.liveSourceRadius.fill(0);
|
|
158
|
+
out.liveProjections.fill(0);
|
|
159
|
+
for (let k = 0; k < out.liveLayers.length; k++)
|
|
160
|
+
out.liveProjections[k * 4 + 3] = 1;
|
|
158
161
|
/*
|
|
159
162
|
* Zero, then written under the owning light below. The weights used to be a straight copy of
|
|
160
163
|
* this set's own two, because the arrays were addressed by live slot; they are addressed by
|
|
@@ -188,6 +191,11 @@ export class LivePointShadowSet {
|
|
|
188
191
|
out.liveFar[shaderSlot] = map.far;
|
|
189
192
|
out.liveNear[shaderSlot] = map.near;
|
|
190
193
|
out.liveSourceRadius[shaderSlot] = map.sourceRadius;
|
|
194
|
+
/* Where the image was rendered from, not where the light is now. See `projections`. */
|
|
195
|
+
out.liveProjections[shaderSlot * 4] = map.originX;
|
|
196
|
+
out.liveProjections[shaderSlot * 4 + 1] = map.originY;
|
|
197
|
+
out.liveProjections[shaderSlot * 4 + 2] = map.originZ;
|
|
198
|
+
out.liveProjections[shaderSlot * 4 + 3] = map.far;
|
|
191
199
|
/*
|
|
192
200
|
* The handover weight *and* the image's own arrival, multiplied.
|
|
193
201
|
*
|
|
@@ -167,13 +167,62 @@ export function runPointShadowBakes(pool, lights, facesPerFrame, liveFacesPerFra
|
|
|
167
167
|
scratch.staleOwner[slot] = lightIndex;
|
|
168
168
|
scratch.staleRun[slot] = 0;
|
|
169
169
|
}
|
|
170
|
-
|
|
170
|
+
/*
|
|
171
|
+
* **The run decays rather than resetting, and it climbs past the threshold before it stops.**
|
|
172
|
+
*
|
|
173
|
+
* A light that wanders on a curve is not stale on *every* frame — near the turning points of
|
|
174
|
+
* its travel it moves less in a frame than the rebake tolerance, so a run that reset to zero
|
|
175
|
+
* lost the tracking allowance there and the map dropped back to two faces a frame. The shadow
|
|
176
|
+
* then travelled smoothly through the fast part of the wander and stepped through the slow
|
|
177
|
+
* part, which is not motion and not stillness: it reads as a vibration. Reported that way,
|
|
178
|
+
* against a brazier, once the tracking allowance was in.
|
|
179
|
+
*
|
|
180
|
+
* So the run is hysteresis. It climbs to twice the threshold, which buys a light that has
|
|
181
|
+
* earned the allowance three quiet frames before it loses it, and decays one frame at a time
|
|
182
|
+
* so a light that genuinely settles stops re-baking rather than holding a budget for ever.
|
|
183
|
+
*/
|
|
184
|
+
const previous = scratch.staleRun[slot] ?? 0;
|
|
185
|
+
const run = stale
|
|
186
|
+
? Math.min(previous + 1, CHRONIC_STALE_FRAMES * 2)
|
|
187
|
+
: Math.max(previous - 1, 0);
|
|
171
188
|
scratch.staleRun[slot] = run;
|
|
172
189
|
scratch.chronic[slot] = run >= CHRONIC_STALE_FRAMES ? 1 : 0;
|
|
173
190
|
}
|
|
174
191
|
const planned = planPointShadowBakes(scratch.stale, scratch.ready, count, scratch.order, scratch.chronic);
|
|
175
192
|
let faces = facesPerFrame;
|
|
176
193
|
let urgentSpent = false;
|
|
194
|
+
/*
|
|
195
|
+
* **One wandering light finishes its cube in the frame it started it**, the same allowance a
|
|
196
|
+
* light with no image at all gets, and for a reason that turns out to be the same one: a map
|
|
197
|
+
* dribbled out at two faces a frame is a map nobody can sample correctly until it lands.
|
|
198
|
+
*
|
|
199
|
+
* A cold light cannot be sampled *at all* until its six faces are in, so it takes the whole
|
|
200
|
+
* cube at once. A chronically stale one can be sampled throughout and is wrong in a quieter
|
|
201
|
+
* way: `planBake` pins the origin for the whole of a resumed bake, so six faces spread over
|
|
202
|
+
* three frames means the published origin steps once every third frame. The shader shoots from
|
|
203
|
+
* that origin, so the shadow it draws steps with it — reported on a brazier as motion that was
|
|
204
|
+
* "really fast, not smooth, snappy, and unrealistic", which is a 20 Hz sample of a flame that
|
|
205
|
+
* wanders about once a second.
|
|
206
|
+
*
|
|
207
|
+
* Given the cube in one frame the origin moves every frame and the shadow travels instead.
|
|
208
|
+
*
|
|
209
|
+
* **Bounded to one light, and it has to be.** Eight braziers each taking six passes is the 48
|
|
210
|
+
* passes and 89 ms `pointShadowFacesPerFrame` exists to prevent. One is the light being shaded
|
|
211
|
+
* that most recently went stale, which is the one somebody is standing next to; the rest keep
|
|
212
|
+
* dribbling under the ordinary budget, and their shadows step rather than travel. That is the
|
|
213
|
+
* right way round: a shadow you are looking at moves, and a shadow across the square does not
|
|
214
|
+
* cost the frame.
|
|
215
|
+
*
|
|
216
|
+
* It is spent *after* the cold and settling groups, because `planPointShadowBakes` orders the
|
|
217
|
+
* chronic ones last — so a light arriving in range still gets its first image ahead of a flame
|
|
218
|
+
* refining one it already has.
|
|
219
|
+
*
|
|
220
|
+
* **What would make it wrong** is a caster set heavy enough that six passes over it does not
|
|
221
|
+
* fit the frame. Measured against a game whose whole static world is about 8,500 triangles in
|
|
222
|
+
* a handful of draws; a world an order of magnitude heavier wants `pointShadowFacesPerFrame`
|
|
223
|
+
* raised or this given up, and `gpuTiming` is how to tell which.
|
|
224
|
+
*/
|
|
225
|
+
let trackingSpent = false;
|
|
177
226
|
for (let i = 0; i < planned && faces > 0; i++) {
|
|
178
227
|
const slot = scratch.order[i] ?? -1;
|
|
179
228
|
/*
|
|
@@ -185,11 +234,16 @@ export function runPointShadowBakes(pool, lights, facesPerFrame, liveFacesPerFra
|
|
|
185
234
|
const map = lightIndex >= 0 ? pool.mapForLight(lightIndex) : undefined;
|
|
186
235
|
if (light === undefined || map === undefined)
|
|
187
236
|
continue;
|
|
188
|
-
const
|
|
189
|
-
const
|
|
190
|
-
|
|
237
|
+
const sampled = slot < pool.sampledCount;
|
|
238
|
+
const cold = sampled && scratch.ready[slot] === 0;
|
|
239
|
+
const urgent = cold && !urgentSpent;
|
|
240
|
+
/* A light being shaded whose map will be stale again next frame however it is served. */
|
|
241
|
+
const tracking = !cold && sampled && scratch.chronic[slot] === 1 && !trackingSpent;
|
|
242
|
+
if (urgent)
|
|
191
243
|
urgentSpent = true;
|
|
192
|
-
|
|
244
|
+
if (tracking)
|
|
245
|
+
trackingSpent = true;
|
|
246
|
+
faces -= bake(map, light, staticCasters, urgent || tracking ? faceCount : faces);
|
|
193
247
|
}
|
|
194
248
|
/*
|
|
195
249
|
* Live maps contain movers only and are composed with the static maps in the shader. A second
|