reze-engine 0.50.2 → 0.50.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/dist/engine.d.ts +294 -7
- package/dist/engine.d.ts.map +1 -1
- package/dist/engine.js +900 -109
- package/dist/shaders/cast-api.d.ts +1 -1
- package/dist/shaders/cast-api.d.ts.map +1 -1
- package/dist/shaders/cast-layout.d.ts +44 -1
- package/dist/shaders/cast-layout.d.ts.map +1 -1
- package/dist/shaders/cast-layout.js +44 -1
- package/dist/shaders/materials/common.d.ts.map +1 -1
- package/dist/shaders/materials/common.js +7 -1
- package/dist/shaders/materials/nodes.d.ts +1 -1
- package/dist/shaders/materials/nodes.d.ts.map +1 -1
- package/dist/shaders/materials/nodes.js +17 -9
- package/dist/shaders/passes/composite.d.ts +1 -1
- package/dist/shaders/passes/composite.d.ts.map +1 -1
- package/dist/shaders/passes/depth-prepass.d.ts +1 -1
- package/dist/shaders/passes/depth-prepass.d.ts.map +1 -1
- package/dist/shaders/passes/depth-prepass.js +52 -12
- package/dist/shaders/passes/ground.d.ts +16 -0
- package/dist/shaders/passes/ground.d.ts.map +1 -1
- package/dist/shaders/passes/ground.js +143 -49
- package/dist/shaders/passes/outline.d.ts +1 -1
- package/dist/shaders/passes/outline.d.ts.map +1 -1
- package/dist/shaders/passes/outline.js +12 -3
- package/dist/shaders/passes/particles.d.ts.map +1 -1
- package/dist/shaders/passes/particles.js +6 -2
- package/dist/shaders/passes/scene-contract.d.ts +38 -6
- package/dist/shaders/passes/scene-contract.d.ts.map +1 -1
- package/dist/shaders/passes/scene-contract.js +53 -16
- package/dist/shaders/passes/trails.d.ts.map +1 -1
- package/dist/shaders/passes/trails.js +36 -8
- package/package.json +2 -2
- package/src/engine.ts +952 -100
- package/src/shaders/cast-layout.ts +44 -1
- package/src/shaders/materials/common.ts +7 -1
- package/src/shaders/materials/nodes.ts +17 -9
- package/src/shaders/passes/depth-prepass.ts +53 -12
- package/src/shaders/passes/ground.ts +145 -49
- package/src/shaders/passes/outline.ts +14 -3
- package/src/shaders/passes/particles.ts +6 -2
- package/src/shaders/passes/scene-contract.ts +55 -16
- package/src/shaders/passes/trails.ts +36 -8
|
@@ -14,7 +14,50 @@
|
|
|
14
14
|
* the whole engine failing to start on an import order nobody chose.
|
|
15
15
|
*/
|
|
16
16
|
export const EFFECT_SUBJECTS = 4
|
|
17
|
-
|
|
17
|
+
/**
|
|
18
|
+
* Bone anchors, for the WHOLE SCENE rather than per effect — this is an address
|
|
19
|
+
* space that every installed effect draws slots from, and an effect asking for
|
|
20
|
+
* one the table has already given away is told so and has it dropped.
|
|
21
|
+
*
|
|
22
|
+
* 8 was reachable, and quietly: two effects each wanting two hands, two feet and
|
|
23
|
+
* a head is ten, so the second one silently lost its ribbons to a diagnostic
|
|
24
|
+
* nobody was reading. 16 is double the headroom for 131KB of storage buffer,
|
|
25
|
+
* where 8 cost 66KB.
|
|
26
|
+
*
|
|
27
|
+
* The cost really is only that. The per-frame upload is bounded by the last
|
|
28
|
+
* TRAILED slot, not by this cap (see the writeBuffer in updateCastBuffer), so a
|
|
29
|
+
* scene using three anchors uploads three anchors' worth whatever this says.
|
|
30
|
+
* The CPU-side path rings are keyed by (model, slot) and allocated on use. And
|
|
31
|
+
* effects never index this directly — they loop to rzTrailCount / rzSubjectCount
|
|
32
|
+
* and read through rzAnchor/rzTrail, both of which bounds-check against it.
|
|
33
|
+
*
|
|
34
|
+
* It stays a compile-time constant because the accessors in cast-api.ts are
|
|
35
|
+
* interpolated into every effect module as WGSL literals; making it dynamic
|
|
36
|
+
* means resizing the buffer and recompiling every installed effect whenever the
|
|
37
|
+
* scene's anchor count grows, which is a different feature from raising a number
|
|
38
|
+
* that was never load-bearing.
|
|
39
|
+
*/
|
|
40
|
+
export const EFFECT_ANCHORS = 16
|
|
41
|
+
/**
|
|
42
|
+
* Path samples kept per trailed anchor — 128 at the 60Hz sampling rate is a
|
|
43
|
+
* ~2.1 second ribbon.
|
|
44
|
+
*
|
|
45
|
+
* Briefly 256, and reverted with the reason, because the reason is the useful
|
|
46
|
+
* part: a ribbon's cost is not its geometry, it is its FRAGMENTS. It is a wide
|
|
47
|
+
* translucent strip blended additively into the HDR target at the pass's sample
|
|
48
|
+
* count, and it overlaps itself — so its cost tracks the screen AREA it covers,
|
|
49
|
+
* and a twice-as-long ribbon covers roughly twice as much.
|
|
50
|
+
*
|
|
51
|
+
* That lands very differently on the two backends. Overdraw and blend bandwidth
|
|
52
|
+
* are what a tile-based GPU pays for most and what an immediate-mode desktop GPU
|
|
53
|
+
* absorbs, which is why ribbons were reported as costing far more on Safari than
|
|
54
|
+
* on Chrome for the same scene. Doubling this doubles the one thing already
|
|
55
|
+
* known to be the bottleneck there.
|
|
56
|
+
*
|
|
57
|
+
* Raising it is still SAFE — effects loop to rzTrailCount and nothing breaks —
|
|
58
|
+
* it is simply not cheap, and the cost shows up on the slower of the two
|
|
59
|
+
* browsers rather than the one it would be measured on.
|
|
60
|
+
*/
|
|
18
61
|
export const EFFECT_TRAIL_SAMPLES = 128
|
|
19
62
|
/** vec4 slot where the trails begin — after the subjects and the anchors. */
|
|
20
63
|
export const EFFECT_TRAIL_BASE = EFFECT_SUBJECTS * 3 + EFFECT_ANCHORS * EFFECT_SUBJECTS * 3
|
|
@@ -73,7 +73,13 @@ struct MaterialUniforms {
|
|
|
73
73
|
};
|
|
74
74
|
|
|
75
75
|
struct VertexOutput {
|
|
76
|
-
@
|
|
76
|
+
// @invariant: the opaque depth prepass rasterises this same skinned position
|
|
77
|
+
// through a DIFFERENT shader module, and the colour pass then depth-tests
|
|
78
|
+
// less-equal against what it wrote. Without invariance a backend is free to
|
|
79
|
+
// optimise the two position computations differently, and a one-ulp
|
|
80
|
+
// disagreement is a pixel of missing character. Invariance pins both to the
|
|
81
|
+
// same result; it costs only that freedom.
|
|
82
|
+
@builtin(position) @invariant position: vec4f,
|
|
77
83
|
@location(0) normal: vec3f,
|
|
78
84
|
@location(1) uv: vec2f,
|
|
79
85
|
@location(2) worldPos: vec3f,
|
|
@@ -216,18 +216,26 @@ fn curve5(t0: f32, y0: f32, y1: f32, y2: f32, y3: f32, y4: f32) -> f32 {
|
|
|
216
216
|
let t = clamp(t0, 0.0, 1.0) * 4.0;
|
|
217
217
|
let i = min(floor(t), 3.0);
|
|
218
218
|
let f = t - i;
|
|
219
|
-
//
|
|
220
|
-
//
|
|
221
|
-
//
|
|
222
|
-
//
|
|
223
|
-
|
|
219
|
+
// NO local array, deliberately. This used to build array<f32, 5> and index it
|
|
220
|
+
// with the runtime k — and a dynamic index on function-local memory is the one
|
|
221
|
+
// construct this codebase has already caught Metal lowering to a
|
|
222
|
+
// per-invocation copy plus a switch (the filmic LUT note in composite.ts).
|
|
223
|
+
// rgb_curve calls this three times per node per pixel, so on a graph-heavy
|
|
224
|
+
// close-up that lowering was paid in the hottest loop the frame has. Four
|
|
225
|
+
// segments select cleanly: the values below are the SAME subtractions the
|
|
226
|
+
// array indexing produced, chosen by k instead of loaded through it —
|
|
227
|
+
// bit-identical results, no local memory, nothing for the backend to spill.
|
|
224
228
|
let k = i32(i);
|
|
225
|
-
let
|
|
226
|
-
let
|
|
229
|
+
let s0 = y1 - y0;
|
|
230
|
+
let s1 = y2 - y1;
|
|
231
|
+
let s2 = y3 - y2;
|
|
232
|
+
let s3 = y4 - y3;
|
|
233
|
+
let pa = select(select(y0, y1, k == 1), select(y2, y3, k == 3), k >= 2);
|
|
234
|
+
let pb = select(select(y1, y2, k == 1), select(y3, y4, k == 3), k >= 2);
|
|
227
235
|
// Secants either side of each knot, clamped at the ends.
|
|
228
|
-
let dPrev = select(ys[max(k, 1)] - ys[max(k, 1) - 1], pb - pa, k == 0);
|
|
229
236
|
let dHere = pb - pa;
|
|
230
|
-
let
|
|
237
|
+
let dPrev = select(s0, select(s1, s2, k == 3), k >= 2);
|
|
238
|
+
let dNext = select(select(s1, s2, k == 1), s3, k >= 2);
|
|
231
239
|
let m0 = curve_slope(dPrev, dHere);
|
|
232
240
|
let m1 = curve_slope(dHere, dNext);
|
|
233
241
|
let f2 = f * f;
|
|
@@ -1,17 +1,36 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
1
|
+
// The depth-only prime, one module behind four pipelines.
|
|
2
|
+
//
|
|
3
|
+
// The oldest fps complaint the engine had — zoom close and the frame drops, in
|
|
4
|
+
// every material generation — was per-fragment shading TIMES layering: an MMD
|
|
5
|
+
// model at close-up is cloth over body over face under hair, author-order drawn,
|
|
6
|
+
// every buried layer fully shaded and then covered. This module lets depth go
|
|
7
|
+
// down FIRST so the colour passes shade each pixel once:
|
|
8
|
+
//
|
|
9
|
+
// · opaque prepass (CUTOFF 0.5) — plain auto-class opaque draws, before the
|
|
10
|
+
// opaque colour walk. See drawOpaqueDepthPrepass for who is in and why.
|
|
11
|
+
// · hair prime (CUTOFF 1.0, stencil not-equal) — between the non-hair and
|
|
12
|
+
// hair colour walks, fenced off the eye silhouette the see-through-hair
|
|
13
|
+
// stencil pass needs. See drawHairDepthPrime.
|
|
14
|
+
// · transparent solid prime (CUTOFF 1.0) — a translucent material's alpha-1
|
|
15
|
+
// texels, where over-blending is plain replacement and the buried work
|
|
16
|
+
// provably never shows. See drawTransparentSolidPrepass.
|
|
17
|
+
// · transparent depth prepass (CUTOFF 0.5, AFTER colour) — the original
|
|
18
|
+
// occupant, dormant: re-records sheer fabric's depth so outline hulls are
|
|
19
|
+
// occluded behind it. Kept for a future OIT path.
|
|
9
20
|
//
|
|
10
21
|
// Reuses mainPipelineLayout: camera g0b0, diffuseSampler g0b2, skinMats g1b0,
|
|
11
22
|
// diffuse texture g2b0, material uniforms g2b1 — the same bind groups the
|
|
12
23
|
// color draws already set, so drawing it costs no extra binding work.
|
|
24
|
+
//
|
|
25
|
+
// A FUNCTION rather than the constant it was, for the reason commonFsOutWgsl is
|
|
26
|
+
// one: the fragment outputs below depend on whether the device carries the id
|
|
27
|
+
// attachment, and that answer does not exist at import time. The constant could
|
|
28
|
+
// not have taken the outputs at all, which is most of why it did not have them.
|
|
13
29
|
|
|
14
|
-
|
|
30
|
+
import { sceneFsOutWgsl, sceneIdPadWgsl } from "./scene-contract"
|
|
31
|
+
|
|
32
|
+
export function transparentDepthPrepassWgsl(): string {
|
|
33
|
+
return /* wgsl */ `
|
|
15
34
|
struct CameraUniforms { view: mat4x4f, projection: mat4x4f, viewPos: vec3f, _p: f32, };
|
|
16
35
|
struct MaterialUniforms {
|
|
17
36
|
diffuseColor: vec3f,
|
|
@@ -25,7 +44,9 @@ struct MaterialUniforms {
|
|
|
25
44
|
@group(2) @binding(1) var<uniform> material: MaterialUniforms;
|
|
26
45
|
|
|
27
46
|
struct VSOut {
|
|
28
|
-
@
|
|
47
|
+
// @invariant, to the same end as the material VertexOutput: the colour pass
|
|
48
|
+
// must land on exactly the depths this wrote.
|
|
49
|
+
@builtin(position) @invariant position: vec4f,
|
|
29
50
|
@location(0) uv: vec2f,
|
|
30
51
|
};
|
|
31
52
|
|
|
@@ -50,8 +71,28 @@ struct VSOut {
|
|
|
50
71
|
return o;
|
|
51
72
|
}
|
|
52
73
|
|
|
53
|
-
|
|
74
|
+
// Every attachment the pass carries, declared and then not written: the
|
|
75
|
+
// pipeline takes all of them at writeMask 0 (see sceneTargets), so what this
|
|
76
|
+
// returns is discarded by the hardware and only the depth write survives — which
|
|
77
|
+
// is the entire purpose of the pass. Declaring them anyway is what keeps the
|
|
78
|
+
// pipeline valid on a browser that requires an output per target rather than
|
|
79
|
+
// per WRITTEN target. Costs one dead struct store on a fragment that already
|
|
80
|
+
// runs, because the alpha test below needs it to.
|
|
81
|
+
${sceneFsOutWgsl({ name: "PrepassOut", aux: "mask" })}
|
|
82
|
+
// The cutout threshold, per pipeline. 0.5 is the OPAQUE prime's "solid enough"
|
|
83
|
+
// — safe there because opaque colour replaces rather than blends. The
|
|
84
|
+
// TRANSPARENT prime overrides it to 1.0: a translucent fragment's blend reads
|
|
85
|
+
// what is behind it, so only a texel at EXACTLY alpha 1 — where over-blending
|
|
86
|
+
// collapses to plain replacement and the destination stops mattering — may
|
|
87
|
+
// claim depth ahead of its buried layers without changing the pixel.
|
|
88
|
+
override CUTOFF: f32 = 0.5;
|
|
89
|
+
@fragment fn fs(in: VSOut) -> PrepassOut {
|
|
54
90
|
let a = material.alpha * textureSample(diffuseTexture, diffuseSampler, in.uv).a;
|
|
55
|
-
if (a <
|
|
91
|
+
if (a < CUTOFF) { discard; }
|
|
92
|
+
var out: PrepassOut;
|
|
93
|
+
out.color = vec4f(0.0);
|
|
94
|
+
out.mask = vec4f(0.0);
|
|
95
|
+
${sceneIdPadWgsl("out")} return out;
|
|
56
96
|
}
|
|
57
97
|
`
|
|
98
|
+
}
|
|
@@ -20,7 +20,13 @@ struct GroundShadowMat {
|
|
|
20
20
|
fadeEnd: f32, shadowStrength: f32, pcfTexel: f32, gridSpacing: f32,
|
|
21
21
|
gridLineWidth: f32, gridLineOpacity: f32, noiseStrength: f32, opacity: f32,
|
|
22
22
|
gridLineColor: vec3f, mirror: f32,
|
|
23
|
-
|
|
23
|
+
// farCascade: 1 while a stage is loaded, 0 otherwise. See the branch below —
|
|
24
|
+
// with no stage the far map is never drawn into, so its taps are known.
|
|
25
|
+
mirrorBlur: f32, farCascade: f32, _mb1: f32, _mb2: f32,
|
|
26
|
+
// Every shadow caster in one sphere, refreshed per frame. w = radius; 0 means
|
|
27
|
+
// nothing casts, negative means "do not use this" (a rigid caster has no
|
|
28
|
+
// sphere, so a scene with a stage keeps the taps). See rzShadowPossible.
|
|
29
|
+
casterSphere: vec4f,
|
|
24
30
|
};
|
|
25
31
|
// One view-projection per shadow cascade, inner to outer — same buffer and
|
|
26
32
|
// same order the materials read.
|
|
@@ -42,33 +48,12 @@ struct MirrorVP { viewProj: mat4x4f, params: vec4f, };
|
|
|
42
48
|
@group(0) @binding(9) var mirrorTex: texture_2d<f32>;
|
|
43
49
|
@group(0) @binding(10) var linearSampler: sampler;
|
|
44
50
|
@group(0) @binding(11) var mirrorDepth: texture_depth_multisampled_2d;
|
|
51
|
+
// The frost noise, PRE-BAKED — see GROUND_NOISE_BAKE_WGSL below. Sampled with
|
|
52
|
+
// the repeat sampler already at binding 10.
|
|
53
|
+
@group(0) @binding(12) var noiseTex: texture_2d<f32>;
|
|
45
54
|
${WORLD_AMBIENT_WGSL}
|
|
46
55
|
${lightsApi(0, 6)}
|
|
47
56
|
|
|
48
|
-
fn hash2(p: vec2f) -> f32 {
|
|
49
|
-
var p3 = fract(vec3f(p.x, p.y, p.x) * 0.1031);
|
|
50
|
-
p3 += dot(p3, vec3f(p3.y + 33.33, p3.z + 33.33, p3.x + 33.33));
|
|
51
|
-
return fract((p3.x + p3.y) * p3.z);
|
|
52
|
-
}
|
|
53
|
-
fn valueNoise(p: vec2f) -> f32 {
|
|
54
|
-
let i = floor(p);
|
|
55
|
-
let f = fract(p);
|
|
56
|
-
let u = f * f * (3.0 - 2.0 * f);
|
|
57
|
-
return mix(mix(hash2(i), hash2(i + vec2f(1.0, 0.0)), u.x),
|
|
58
|
-
mix(hash2(i + vec2f(0.0, 1.0)), hash2(i + vec2f(1.0, 1.0)), u.x), u.y);
|
|
59
|
-
}
|
|
60
|
-
fn fbmNoise(p: vec2f) -> f32 {
|
|
61
|
-
var v = 0.0;
|
|
62
|
-
var a = 0.5;
|
|
63
|
-
var pp = p;
|
|
64
|
-
for (var i = 0; i < 4; i++) {
|
|
65
|
-
v += a * valueNoise(pp);
|
|
66
|
-
pp *= 2.0;
|
|
67
|
-
a *= 0.5;
|
|
68
|
-
}
|
|
69
|
-
return v;
|
|
70
|
-
}
|
|
71
|
-
|
|
72
57
|
struct VO { @builtin(position) position: vec4f, @location(0) worldPos: vec3f, @location(1) normal: vec3f, };
|
|
73
58
|
@vertex fn vs(@location(0) position: vec3f, @location(1) normal: vec3f, @location(2) uv: vec2f) -> VO {
|
|
74
59
|
var o: VO; o.worldPos = position; o.normal = normal;
|
|
@@ -121,8 +106,63 @@ ${sceneFsOutWgsl()}@fragment fn fs(i: VO) -> FSOut {
|
|
|
121
106
|
// The far cascade's taps run ONLY where the near one is fading or absent
|
|
122
107
|
// (frustum < 1), so a pixel in the near core costs exactly what it did with
|
|
123
108
|
// one cascade — and that core is where the camera usually looks.
|
|
109
|
+
// Can anything cast a shadow ONTO this point at all?
|
|
110
|
+
//
|
|
111
|
+
// The ground is vastly larger than what stands on it, and the answer for most
|
|
112
|
+
// of it is no. A point is shadowed only if the ray from it toward the light
|
|
113
|
+
// meets a caster, so testing that ray against one bounding sphere decides in a
|
|
114
|
+
// few ALU ops what nine hardware-bilinear depth comparisons would otherwise be
|
|
115
|
+
// spent discovering. Same pixels: outside the sphere vis stays 1.0, which is
|
|
116
|
+
// exactly what the taps return.
|
|
117
|
+
//
|
|
118
|
+
// Conservative in all three directions that matter. The sphere is a union of
|
|
119
|
+
// per-model bounds built from POSED bones plus the skin margin, so a jump or a
|
|
120
|
+
// flying skirt is inside it. A negative radius disables the test outright. And
|
|
121
|
+
// the halfspace check uses -R rather than 0, so a caster the point is standing
|
|
122
|
+
// inside still counts.
|
|
123
|
+
var shadowPossible = material.casterSphere.w > 0.0;
|
|
124
|
+
if (shadowPossible) {
|
|
125
|
+
let toLight = normalize(-light.lights[0].direction.xyz);
|
|
126
|
+
let toCaster = material.casterSphere.xyz - i.worldPos;
|
|
127
|
+
let along = dot(toCaster, toLight);
|
|
128
|
+
let perp = length(toCaster - toLight * along);
|
|
129
|
+
shadowPossible = along > -material.casterSphere.w && perp <= material.casterSphere.w;
|
|
130
|
+
} else {
|
|
131
|
+
// Negative radius is the opt-out, not "nothing casts".
|
|
132
|
+
shadowPossible = material.casterSphere.w < 0.0;
|
|
133
|
+
}
|
|
134
|
+
|
|
124
135
|
var vis = 1.0;
|
|
125
|
-
|
|
136
|
+
// The whole cascade lookup, behind the strength dial.
|
|
137
|
+
//
|
|
138
|
+
// This is a FULL-COVERAGE draw and each branch below is nine comparison-sampler
|
|
139
|
+
// taps per pixel — eighteen where the cascades overlap — so on a retina canvas
|
|
140
|
+
// it is tens of millions of shadow-map fetches a frame. All of it was computed
|
|
141
|
+
// and then multiplied by material.shadowStrength, which is ZERO whenever the
|
|
142
|
+
// host turns the ground's shadow catcher off. Turning shadows off in the UI
|
|
143
|
+
// therefore cost exactly what leaving them on cost, which is what a report of
|
|
144
|
+
// "no fps gain from disabling shadows" looks like from the outside.
|
|
145
|
+
//
|
|
146
|
+
// shadowStrength is a uniform, so this branch is uniform across the draw:
|
|
147
|
+
// every invocation takes the same side and the taps are genuinely skipped
|
|
148
|
+
// rather than merely masked. The dark term below is (1.0 - vis) * strength, so
|
|
149
|
+
// with strength at zero the result is identical either way — a pure
|
|
150
|
+
// deletion of work, not a change of look.
|
|
151
|
+
//
|
|
152
|
+
// The same reasoning the noise tint below already got, applied to the term
|
|
153
|
+
// that costs a hundred times more.
|
|
154
|
+
if (material.shadowStrength > 0.0 && shadowPossible) {
|
|
155
|
+
// The far cascade's taps, skipped entirely when nothing ever drew into it.
|
|
156
|
+
//
|
|
157
|
+
// This branch is the expensive one on a wide floor: it runs wherever the NEAR
|
|
158
|
+
// cascade does not reach, and the near cascade is a 64-unit box around the
|
|
159
|
+
// camera target, so a floor receding to the horizon takes it almost
|
|
160
|
+
// everywhere. Nine comparison taps, per pixel, on the largest draw in the
|
|
161
|
+
// frame — against a map that is cleared unless a stage is in the scene.
|
|
162
|
+
//
|
|
163
|
+
// Cleared depth compares as "no occluder", so the skipped path leaves vis at
|
|
164
|
+
// 1.0, which is exactly what the taps returned. Free, not cheaper.
|
|
165
|
+
if (material.farCascade > 0.0 && frustum < 1.0 && frustum1 > 0.0) {
|
|
126
166
|
let suv1 = vec2f(ndc1.x * 0.5 + 0.5, 0.5 - ndc1.y * 0.5);
|
|
127
167
|
let suv1_c = clamp(suv1, vec2f(0.02), vec2f(0.98));
|
|
128
168
|
let st1 = ${1 / SHADOW_CASCADES[SHADOW_CASCADES.length - 1].mapSize} * 2.0;
|
|
@@ -152,12 +192,27 @@ ${sceneFsOutWgsl()}@fragment fn fs(i: VO) -> FSOut {
|
|
|
152
192
|
// cascade to cascade rather than snapping to lit mid-floor.
|
|
153
193
|
vis = mix(vis, acc * (1.0 / 9.0), frustum);
|
|
154
194
|
}
|
|
195
|
+
}
|
|
155
196
|
|
|
156
197
|
// Frosted/matte micro-texture. Scenes leaving it at zero were still paying
|
|
157
198
|
// sixteen hash rounds a pixel for a tint of exactly 1.
|
|
158
199
|
var noiseTint = 1.0;
|
|
159
200
|
if (material.noiseStrength != 0.0) {
|
|
160
|
-
|
|
201
|
+
// One texture sample where four octaves of value noise used to be
|
|
202
|
+
// EVALUATED — sixteen hash rounds per pixel, on the largest draw in the
|
|
203
|
+
// frame. Bisection on the device that was slow pinned the ground's whole
|
|
204
|
+
// cost here: flat colour was smooth, the shader minus its shadow taps was
|
|
205
|
+
// still slow, and the shader minus only this was smooth. (0.33.2 built
|
|
206
|
+
// this same bake and measured no win — the cost then was the PCF. The
|
|
207
|
+
// instrument was right both times; the term changed.)
|
|
208
|
+
//
|
|
209
|
+
// The tile is the SAME fbm, baked once at init over a 64-p-unit period
|
|
210
|
+
// (~21 world units before it repeats — invisible at frost strengths), and
|
|
211
|
+
// the interior of the tile is bit-identical to the old evaluation because
|
|
212
|
+
// only lattice cells on the seam are wrapped. Sampled at level 0 with the
|
|
213
|
+
// repeat sampler: the old evaluation had no filtering either, so distant
|
|
214
|
+
// sparkle is unchanged rather than quietly "improved".
|
|
215
|
+
let noiseVal = textureSampleLevel(noiseTex, linearSampler, i.worldPos.xz * ${3 / 64}, 0.0).r;
|
|
161
216
|
noiseTint = 1.0 + (noiseVal - 0.5) * material.noiseStrength;
|
|
162
217
|
}
|
|
163
218
|
|
|
@@ -186,8 +241,8 @@ ${sceneFsOutWgsl()}@fragment fn fs(i: VO) -> FSOut {
|
|
|
186
241
|
// reflection was rendered by the MIRROR camera, so projecting this
|
|
187
242
|
// fragment's world position through that camera yields exactly the texel
|
|
188
243
|
// where its reflection landed — projective mapping, no screen-space guess.
|
|
189
|
-
// There is deliberately NO strength dial
|
|
190
|
-
//
|
|
244
|
+
// There is deliberately NO strength dial: mirror is on or off, and how much
|
|
245
|
+
// of it shows is the SURFACE's own opacity covering it — the same
|
|
191
246
|
// independence the grid won earlier. The branch is on a uniform, so the
|
|
192
247
|
// whole cost vanishes for the scenes that leave it off.
|
|
193
248
|
var reflShadowed = vec3f(0.0);
|
|
@@ -250,28 +305,18 @@ ${sceneFsOutWgsl()}@fragment fn fs(i: VO) -> FSOut {
|
|
|
250
305
|
// and a catcher stacked on top would darken the same shadow twice.
|
|
251
306
|
let catchA = dark * 0.65 * edgeFade * (1.0 - material.opacity) * (1.0 - material.mirror);
|
|
252
307
|
|
|
253
|
-
// FOUR LAYERS, premultiplied, bottom to top: received shadow,
|
|
254
|
-
// surface,
|
|
255
|
-
//
|
|
256
|
-
//
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
//
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
//
|
|
263
|
-
// thing it is made of happens to be.
|
|
264
|
-
//
|
|
265
|
-
// The catcher's order against it no longer matters — catchA already carries
|
|
266
|
-
// (1 - mirror), so the two are never both present.
|
|
267
|
-
var pm = vec3f(0.0);
|
|
268
|
-
var cov = catchA;
|
|
269
|
-
// Surface over the received shadow.
|
|
308
|
+
// FOUR LAYERS, premultiplied, bottom to top: reflection, received shadow,
|
|
309
|
+
// ground surface, grid. Each with its own coverage, none scaling another —
|
|
310
|
+
// ground opacity REVEALS the mirror rather than the mirror carrying a
|
|
311
|
+
// strength of its own.
|
|
312
|
+
var pm = reflShadowed * mirrA;
|
|
313
|
+
var cov = mirrA;
|
|
314
|
+
// Shadow catcher over the reflection-or-nothing: black, it covers.
|
|
315
|
+
pm = pm * (1.0 - catchA);
|
|
316
|
+
cov = catchA + cov * (1.0 - catchA);
|
|
317
|
+
// Surface over shadow.
|
|
270
318
|
pm = baseColor * surfA + pm * (1.0 - surfA);
|
|
271
319
|
cov = surfA + cov * (1.0 - surfA);
|
|
272
|
-
// The reflection over the surface, at full strength whatever the opacity.
|
|
273
|
-
pm = reflShadowed * mirrA + pm * (1.0 - mirrA);
|
|
274
|
-
cov = mirrA + cov * (1.0 - mirrA);
|
|
275
320
|
// Grid over surface. Unshadowed, as it has always been — a painted line reads
|
|
276
321
|
// as paint, and darkening it was never what the mix did either.
|
|
277
322
|
pm = material.gridLineColor * gridA + pm * (1.0 - gridA);
|
|
@@ -301,3 +346,54 @@ ${sceneIdWriteWgsl("out", `${GROUND_MATERIAL_ID}u`, `${GROUND_OBJECT_ID}u`)} re
|
|
|
301
346
|
*/
|
|
302
347
|
export const GROUND_MATERIAL_ID = 0xffff
|
|
303
348
|
export const GROUND_OBJECT_ID = 0xffff
|
|
349
|
+
|
|
350
|
+
/** Texel edge of the baked frost tile. 1024 over a 64-p-unit period is two
|
|
351
|
+
* texels per finest-octave noise cell — the bake resolves everything the
|
|
352
|
+
* evaluation produced. r8unorm: the value lives in [0,1] and feeds a 0.05
|
|
353
|
+
* tint; eight bits is already 5x below what that tint can show. */
|
|
354
|
+
export const GROUND_NOISE_SIZE = 1024
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* The frost fbm as a bake: identical math, periodic lattice.
|
|
358
|
+
*
|
|
359
|
+
* hash2/valueNoise/fbm are the ground's old helpers verbatim, with one change —
|
|
360
|
+
* each octave's lattice wraps at its own period (64 p-units at octave 0,
|
|
361
|
+
* doubling with frequency), so the tile is seamless under the repeat sampler.
|
|
362
|
+
* Wrapping only renames lattice cells on the seam; every interior cell hashes
|
|
363
|
+
* exactly as before, which is what makes the bake a relocation of the old
|
|
364
|
+
* pixels rather than a new look.
|
|
365
|
+
*/
|
|
366
|
+
export const GROUND_NOISE_BAKE_WGSL = /* wgsl */ `
|
|
367
|
+
@vertex fn vs(@builtin(vertex_index) vi: u32) -> @builtin(position) vec4f {
|
|
368
|
+
let x = f32((vi & 1u) << 2u) - 1.0;
|
|
369
|
+
let y = f32((vi & 2u) << 1u) - 1.0;
|
|
370
|
+
return vec4f(x, y, 0.0, 1.0);
|
|
371
|
+
}
|
|
372
|
+
fn hash2(p: vec2f) -> f32 {
|
|
373
|
+
var p3 = fract(vec3f(p.x, p.y, p.x) * 0.1031);
|
|
374
|
+
p3 += dot(p3, vec3f(p3.y + 33.33, p3.z + 33.33, p3.x + 33.33));
|
|
375
|
+
return fract((p3.x + p3.y) * p3.z);
|
|
376
|
+
}
|
|
377
|
+
fn wrapCell(i: vec2f, period: f32) -> vec2f { return i - period * floor(i / period); }
|
|
378
|
+
fn valueNoiseP(p: vec2f, period: f32) -> f32 {
|
|
379
|
+
let i = floor(p);
|
|
380
|
+
let f = fract(p);
|
|
381
|
+
let u = f * f * (3.0 - 2.0 * f);
|
|
382
|
+
return mix(mix(hash2(wrapCell(i, period)), hash2(wrapCell(i + vec2f(1.0, 0.0), period)), u.x),
|
|
383
|
+
mix(hash2(wrapCell(i + vec2f(0.0, 1.0), period)), hash2(wrapCell(i + vec2f(1.0, 1.0), period)), u.x), u.y);
|
|
384
|
+
}
|
|
385
|
+
@fragment fn fs(@builtin(position) pos: vec4f) -> @location(0) vec4f {
|
|
386
|
+
let p = pos.xy * (64.0 / ${GROUND_NOISE_SIZE}.0);
|
|
387
|
+
var v = 0.0;
|
|
388
|
+
var a = 0.5;
|
|
389
|
+
var pp = p;
|
|
390
|
+
var period = 64.0;
|
|
391
|
+
for (var o = 0; o < 4; o++) {
|
|
392
|
+
v += a * valueNoiseP(pp, period);
|
|
393
|
+
pp *= 2.0;
|
|
394
|
+
period *= 2.0;
|
|
395
|
+
a *= 0.5;
|
|
396
|
+
}
|
|
397
|
+
return vec4f(v, 0.0, 0.0, 1.0);
|
|
398
|
+
}
|
|
399
|
+
`
|
|
@@ -12,7 +12,14 @@
|
|
|
12
12
|
// margins) — sheer fabric gets a proportional rim, never a solid black
|
|
13
13
|
// hull, without dropping the author's edge flag.
|
|
14
14
|
|
|
15
|
-
|
|
15
|
+
// A FUNCTION rather than a constant, for the reason commonFsOutWgsl is one: the
|
|
16
|
+
// fragment outputs depend on whether the device carries the id attachment, and
|
|
17
|
+
// a string baked at import cannot know what the device said.
|
|
18
|
+
|
|
19
|
+
import { sceneFsOutWgsl, sceneIdPadWgsl } from "./scene-contract"
|
|
20
|
+
|
|
21
|
+
export function outlineShaderWgsl(): string {
|
|
22
|
+
return /* wgsl */ `
|
|
16
23
|
struct CameraUniforms {
|
|
17
24
|
view: mat4x4f,
|
|
18
25
|
projection: mat4x4f,
|
|
@@ -91,7 +98,10 @@ struct VertexOutput {
|
|
|
91
98
|
return output;
|
|
92
99
|
}
|
|
93
100
|
|
|
94
|
-
|
|
101
|
+
// The id output is declared and padded, never written for real: a hull would
|
|
102
|
+
// overwrite the id of the body it traces, so the pipeline takes the id target at
|
|
103
|
+
// writeMask 0. Declared all the same — see sceneFsOutWgsl.
|
|
104
|
+
${sceneFsOutWgsl({ name: "FSOut", aux: "mask" })}
|
|
95
105
|
@fragment fn fs(input: VertexOutput) -> FSOut {
|
|
96
106
|
// Rim alpha FOLLOWS the fabric's texture alpha instead of a hard alpha test:
|
|
97
107
|
// MMD draws blend-material edges solid (only cutout materials alpha-test), so
|
|
@@ -106,6 +116,7 @@ struct FSOut { @location(0) color: vec4f, @location(1) mask: vec4f };
|
|
|
106
116
|
var out: FSOut;
|
|
107
117
|
out.color = vec4f(material.edgeColor.rgb, material.edgeColor.a * texA);
|
|
108
118
|
out.mask = vec4f(1.0, 1.0, 0.0, out.color.a);
|
|
109
|
-
return out;
|
|
119
|
+
${sceneIdPadWgsl("out")} return out;
|
|
110
120
|
}
|
|
111
121
|
`
|
|
122
|
+
}
|
|
@@ -5,6 +5,7 @@ import { anchorAliasWgsl } from "../anchor-table"
|
|
|
5
5
|
import { midiApi } from "../midi-api"
|
|
6
6
|
import { CAST_API } from "../cast-api"
|
|
7
7
|
import { clockApi, EFFECT_MATH_API, PARTICLE_STRUCT_WGSL, trailSlotsApi, viewportApi } from "./hosted-api"
|
|
8
|
+
import { sceneIdFieldWgsl, sceneIdPadWgsl } from "./scene-contract"
|
|
8
9
|
// GPU particles for user effects: a compute step and an instanced quad draw.
|
|
9
10
|
//
|
|
10
11
|
// Its own shader MODULE rather than more source spliced into composite.ts, for
|
|
@@ -307,7 +308,10 @@ struct FSOut {
|
|
|
307
308
|
// rejected outright — "reading alpha but it is missing from fragment output".
|
|
308
309
|
// The material shaders declare vec4f here for the same reason.
|
|
309
310
|
@location(1) mask: vec4f,
|
|
310
|
-
|
|
311
|
+
// Declared whenever the pass carries the attachment, and padded rather than
|
|
312
|
+
// written: a particle is not a thing you would address by id, so its pipeline
|
|
313
|
+
// takes this target at writeMask 0. Declared anyway — see sceneFsOutWgsl.
|
|
314
|
+
${sceneIdFieldWgsl()}}
|
|
311
315
|
|
|
312
316
|
@fragment
|
|
313
317
|
fn fs(in: VSOut) -> FSOut {
|
|
@@ -333,7 +337,7 @@ fn fs(in: VSOut) -> FSOut {
|
|
|
333
337
|
// not geometry, was the banding that survived every geometry fix.
|
|
334
338
|
let mg = select(vec2f(select(0.0, 1.0, BLOOM), 1.0), vec2f(select(0.0, c.a, BLOOM), c.a), ADDITIVE);
|
|
335
339
|
out.mask = vec4f(mg.x, mg.y, 0.0, c.a);
|
|
336
|
-
return out;
|
|
340
|
+
${sceneIdPadWgsl("out")} return out;
|
|
337
341
|
}
|
|
338
342
|
`
|
|
339
343
|
)
|
|
@@ -143,12 +143,14 @@ const ADD_PREMULTIPLIED: GPUBlendState = {
|
|
|
143
143
|
}
|
|
144
144
|
|
|
145
145
|
/**
|
|
146
|
-
* Which classes
|
|
146
|
+
* Which classes write a MEANINGFUL id.
|
|
147
147
|
*
|
|
148
|
-
*
|
|
149
|
-
*
|
|
150
|
-
*
|
|
151
|
-
*
|
|
148
|
+
* Not the same question as which shaders declare the output — every scene-pass
|
|
149
|
+
* shader declares all three now (see sceneFsOutWgsl). This set decides only
|
|
150
|
+
* whose id survives: writeMask 0xf here, writeMask 0 for everyone else, so the
|
|
151
|
+
* others compute an id nothing stores. Leaving the target off those pipelines
|
|
152
|
+
* is not available: every pipeline in a pass must agree with the pass's
|
|
153
|
+
* attachments.
|
|
152
154
|
*
|
|
153
155
|
* The ground is in because a mark placed by id needs the floor to have one.
|
|
154
156
|
* Transparent fabric writes ids too — dissolving a dress needs the dress's own
|
|
@@ -184,11 +186,10 @@ const BLENDS: Record<Exclude<SceneRenderClass, "depth-prepass">, [GPUBlendState,
|
|
|
184
186
|
export function sceneTargets(cls: SceneRenderClass, formats: SceneFormats): GPUColorTargetState[] {
|
|
185
187
|
const targets: GPUColorTargetState[] =
|
|
186
188
|
cls === "depth-prepass"
|
|
187
|
-
? // Format only, and writeMask 0
|
|
188
|
-
//
|
|
189
|
-
//
|
|
190
|
-
//
|
|
191
|
-
// direction, and it is the only one this file ever uses.
|
|
189
|
+
? // Format only, and writeMask 0 — the pipeline writes no colour. It still
|
|
190
|
+
// DECLARES every output, which is the part that used to be missing; see
|
|
191
|
+
// the note on sceneFsOutWgsl about why writeMask 0 is not a licence to
|
|
192
|
+
// leave the output off.
|
|
192
193
|
[
|
|
193
194
|
{ format: formats.hdr, writeMask: 0 },
|
|
194
195
|
{ format: formats.aux, writeMask: 0 },
|
|
@@ -226,12 +227,19 @@ export function sceneColorFormats(formats: SceneFormats): GPUTextureFormat[] {
|
|
|
226
227
|
* author. The struct and the targets above have to agree on count and order,
|
|
227
228
|
* and they now disagree in one file rather than in five.
|
|
228
229
|
*
|
|
229
|
-
*
|
|
230
|
-
*
|
|
231
|
-
*
|
|
232
|
-
*
|
|
233
|
-
*
|
|
234
|
-
*
|
|
230
|
+
* EVERY scene-pass shader takes this — outline, particles, ribbons and the
|
|
231
|
+
* depth prepass included, none of which write a meaningful id (and the prepass
|
|
232
|
+
* no meaningful colour either). They did not, once. The reasoning was that a
|
|
233
|
+
* target at writeMask 0 needs no matching output: true of Dawn, and the reading
|
|
234
|
+
* of gpuweb#1918 this file used to assert. It is not a reading every browser
|
|
235
|
+
* shares, and the failure mode when a browser disagrees is the worst kind —
|
|
236
|
+
* createRenderPipeline does not throw, it returns a pipeline that is already
|
|
237
|
+
* invalid, and the pass that binds it is dropped whole. Missing geometry, clean
|
|
238
|
+
* console, and a bug that reproduces on one vendor's browser only.
|
|
239
|
+
*
|
|
240
|
+
* So the rule is now the strict one, and it costs nothing to hold: declare
|
|
241
|
+
* every output the pass has attachments for, and let writeMask decide what is
|
|
242
|
+
* kept. A shader with no id to write pads it — see sceneIdPadWgsl.
|
|
235
243
|
*/
|
|
236
244
|
export function sceneFsOutWgsl(opts?: { name?: string; aux?: string }): string {
|
|
237
245
|
const name = opts?.name ?? "FSOut"
|
|
@@ -264,3 +272,34 @@ ${id}};
|
|
|
264
272
|
export function sceneIdWriteWgsl(out: string, material: string, object: string): string {
|
|
265
273
|
return mrtIds ? ` ${out}.id = vec2u(${material}, ${object});\n` : ""
|
|
266
274
|
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Just the id FIELD, for a shader that keeps its own struct.
|
|
278
|
+
*
|
|
279
|
+
* Particles and ribbons declare their outputs by hand because the comments on
|
|
280
|
+
* their aux field are about their own blend and belong beside it — taking the
|
|
281
|
+
* whole struct from sceneFsOutWgsl would move that prose away from what it
|
|
282
|
+
* explains. They still need the id output when the pass has the attachment, so
|
|
283
|
+
* they splice this in and pad it with sceneIdPadWgsl.
|
|
284
|
+
*/
|
|
285
|
+
export function sceneIdFieldWgsl(): string {
|
|
286
|
+
return mrtIds ? ` @location(2) id: vec2u,\n` : ""
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* The id assignment for a shader that has no id to give.
|
|
291
|
+
*
|
|
292
|
+
* The counterpart to the strict rule on sceneFsOutWgsl: outline hulls,
|
|
293
|
+
* particles, ribbons and the depth prepass all declare the output because the
|
|
294
|
+
* pass has the attachment, and all of them take writeMask 0, so what they
|
|
295
|
+
* assign is never stored. Zero, which is the reserved "nothing" id the pass
|
|
296
|
+
* clears to — so if one of these ever did reach the target, it would read as
|
|
297
|
+
* absence rather than as a plausible wrong answer pointing at material 0.
|
|
298
|
+
*
|
|
299
|
+
* A separate function from sceneIdWriteWgsl rather than a call to it with "0u"
|
|
300
|
+
* twice, because the two say different things: that one records an identity,
|
|
301
|
+
* this one satisfies a declaration.
|
|
302
|
+
*/
|
|
303
|
+
export function sceneIdPadWgsl(out: string): string {
|
|
304
|
+
return mrtIds ? ` ${out}.id = vec2u(0u, 0u);\n` : ""
|
|
305
|
+
}
|