reze-engine 0.42.2 → 0.43.0
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/README.md +29 -1
- package/dist/camera-animation.d.ts +8 -0
- package/dist/camera-animation.d.ts.map +1 -1
- package/dist/camera-animation.js +10 -0
- package/dist/engine.d.ts +110 -0
- package/dist/engine.d.ts.map +1 -1
- package/dist/engine.js +746 -23
- package/dist/model.d.ts.map +1 -1
- package/dist/model.js +29 -0
- package/dist/physics/autofit.d.ts +147 -0
- package/dist/physics/autofit.d.ts.map +1 -0
- package/dist/physics/autofit.js +501 -0
- package/dist/shaders/audio-api.d.ts +3 -0
- package/dist/shaders/audio-api.d.ts.map +1 -0
- package/dist/shaders/audio-api.js +81 -0
- package/dist/shaders/passes/composite.d.ts +10 -0
- package/dist/shaders/passes/composite.d.ts.map +1 -1
- package/dist/shaders/passes/composite.js +75 -12
- package/dist/shaders/passes/ground-noise.d.ts +7 -0
- package/dist/shaders/passes/ground-noise.d.ts.map +1 -0
- package/dist/shaders/passes/ground-noise.js +88 -0
- package/dist/shaders/passes/particles.d.ts +58 -0
- package/dist/shaders/passes/particles.d.ts.map +1 -0
- package/dist/shaders/passes/particles.js +351 -0
- package/dist/shaders/passes/trails.d.ts +47 -0
- package/dist/shaders/passes/trails.d.ts.map +1 -0
- package/dist/shaders/passes/trails.js +375 -0
- package/package.json +2 -2
- package/src/camera-animation.ts +11 -0
- package/src/engine.ts +806 -20
- package/src/model.ts +28 -0
- package/src/shaders/audio-api.ts +82 -0
- package/src/shaders/passes/composite.ts +76 -11
- package/src/shaders/passes/particles.ts +398 -0
- package/src/shaders/passes/trails.ts +406 -0
- package/dist/physics-debug.d.ts +0 -30
- package/dist/physics-debug.d.ts.map +0 -1
- package/dist/physics-debug.js +0 -526
- package/dist/shaders/materials/body.d.ts +0 -2
- package/dist/shaders/materials/body.d.ts.map +0 -1
- package/dist/shaders/materials/body.js +0 -95
- package/dist/shaders/materials/cloth_rough.d.ts +0 -2
- package/dist/shaders/materials/cloth_rough.d.ts.map +0 -1
- package/dist/shaders/materials/cloth_rough.js +0 -69
- package/dist/shaders/materials/cloth_smooth.d.ts +0 -2
- package/dist/shaders/materials/cloth_smooth.d.ts.map +0 -1
- package/dist/shaders/materials/cloth_smooth.js +0 -61
- package/dist/shaders/materials/default.d.ts +0 -2
- package/dist/shaders/materials/default.d.ts.map +0 -1
- package/dist/shaders/materials/default.js +0 -43
- package/dist/shaders/materials/eye.d.ts +0 -2
- package/dist/shaders/materials/eye.d.ts.map +0 -1
- package/dist/shaders/materials/eye.js +0 -60
- package/dist/shaders/materials/face.d.ts +0 -2
- package/dist/shaders/materials/face.d.ts.map +0 -1
- package/dist/shaders/materials/face.js +0 -95
- package/dist/shaders/materials/hair.d.ts +0 -2
- package/dist/shaders/materials/hair.d.ts.map +0 -1
- package/dist/shaders/materials/hair.js +0 -90
- package/dist/shaders/materials/metal.d.ts +0 -2
- package/dist/shaders/materials/metal.d.ts.map +0 -1
- package/dist/shaders/materials/metal.js +0 -77
- package/dist/shaders/materials/mmd_classic.d.ts +0 -2
- package/dist/shaders/materials/mmd_classic.d.ts.map +0 -1
- package/dist/shaders/materials/mmd_classic.js +0 -66
- package/dist/shaders/materials/stockings.d.ts +0 -2
- package/dist/shaders/materials/stockings.d.ts.map +0 -1
- package/dist/shaders/materials/stockings.js +0 -122
- package/dist/shaders/passes/physics-debug.d.ts +0 -2
- package/dist/shaders/passes/physics-debug.d.ts.map +0 -1
- package/dist/shaders/passes/physics-debug.js +0 -69
package/src/model.ts
CHANGED
|
@@ -2340,6 +2340,34 @@ export class Model {
|
|
|
2340
2340
|
// morph-touched bone at its first posed frame.
|
|
2341
2341
|
this.undoBoneMorphs()
|
|
2342
2342
|
|
|
2343
|
+
// Clear last frame's IK result off the chain links, so a solve starts from
|
|
2344
|
+
// the animation instead of from itself.
|
|
2345
|
+
//
|
|
2346
|
+
// solveIK bakes its output into localRotations, and applyPoseFromClip only
|
|
2347
|
+
// writes bones the clip actually KEYS. A motion that keys 足IK but not the
|
|
2348
|
+
// knees — which is most of them — therefore fed each frame's solve the
|
|
2349
|
+
// previous frame's solved knee: θₙ = g(θₙ₋₁), where MMD computes
|
|
2350
|
+
// θₙ = g(rest) afresh every frame. Near the two-bone chain's collinear
|
|
2351
|
+
// singularity that map is expansive, and the knee falls into a period-2
|
|
2352
|
+
// limit cycle: the foot stays pinned by IK while the thigh and calf whip
|
|
2353
|
+
// around it, frame after frame. Rigs with a generous knee pre-bend and a
|
|
2354
|
+
// tight extension limit never reach the singular zone, which is why this
|
|
2355
|
+
// read as a model-specific bug rather than an engine one.
|
|
2356
|
+
//
|
|
2357
|
+
// Links only, and only when a pose source is about to run: a blanket reset
|
|
2358
|
+
// would wipe bones the host posed by hand through localRotations, and a
|
|
2359
|
+
// suspended clip means the current pose IS the authority.
|
|
2360
|
+
if (!this.clipApplySuspended) {
|
|
2361
|
+
const solvers = this.runtimeSkeleton.ikSolvers
|
|
2362
|
+
if (solvers) {
|
|
2363
|
+
const rots = this.runtimeSkeleton.localRotations
|
|
2364
|
+
for (const solver of solvers) {
|
|
2365
|
+
if (this.ikDisabled.has(solver.ikBoneIndex)) continue
|
|
2366
|
+
for (const link of solver.links) rots[link.boneIndex].setIdentity()
|
|
2367
|
+
}
|
|
2368
|
+
}
|
|
2369
|
+
}
|
|
2370
|
+
|
|
2343
2371
|
if (!this.clipApplySuspended) {
|
|
2344
2372
|
if (this.oneShot !== null) {
|
|
2345
2373
|
this.applyOneShot(deltaTime)
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// The audio data interface, as WGSL — shared verbatim by every effect module.
|
|
2
|
+
//
|
|
3
|
+
// The buffer is PRECOMPUTED for the whole track and sampled by time, never fed
|
|
4
|
+
// live from an AnalyserNode. That is not an optimisation: an export steps the
|
|
5
|
+
// engine frame by frame instead of playing in real time, so a live analyser
|
|
6
|
+
// would return silence during a render and every audio-reactive effect would
|
|
7
|
+
// quietly vanish from the exported video — the same class of bug the scene
|
|
8
|
+
// clock exists to prevent. Layout: an 8-float header [frames, bands,
|
|
9
|
+
// secondsPerFrame, audioTime, playing, 3 spare] then per frame
|
|
10
|
+
// [level, onset, band0..bandN-1]. audioTime and playing are rewritten every frame by
|
|
11
|
+
// whoever owns playback — the editor's audio clock, the viewer's, or the export
|
|
12
|
+
// loop — so "now" is always the clock that is actually running.
|
|
13
|
+
//
|
|
14
|
+
// Every module binds the SAME buffer, so a spawn rule in a particle effect and
|
|
15
|
+
// a bar in a background read identical numbers for the same instant.
|
|
16
|
+
|
|
17
|
+
/** The rzAudio* accessors, with the buffer declared at the given binding. */
|
|
18
|
+
export function audioApi(group: number, binding: number): string {
|
|
19
|
+
return /* wgsl */ `
|
|
20
|
+
@group(${group}) @binding(${binding}) var<storage, read> _rzAudio: array<f32>;
|
|
21
|
+
|
|
22
|
+
/** Frames of analysis available; 0 when the scene has no audio. */
|
|
23
|
+
fn rzAudioFrames() -> i32 { return i32(_rzAudio[0]); }
|
|
24
|
+
/** 1 while the track is actually PLAYING, 0 paused or absent — so an effect can
|
|
25
|
+
* go calm instead of oscillating over a frozen spectrum. */
|
|
26
|
+
fn rzAudioPlaying() -> f32 { return select(0.0, _rzAudio[4], i32(_rzAudio[0]) > 0); }
|
|
27
|
+
/** Where the track is NOW, in seconds — the clock rzAudioLevelAt offsets hang
|
|
28
|
+
* from. An effect that detects an onset k seconds back can anchor a ring to
|
|
29
|
+
* the ABSOLUTE moment of the hit and age it continuously. */
|
|
30
|
+
fn rzAudioTime() -> f32 { return _rzAudio[3]; }
|
|
31
|
+
/** Bands per frame — log-spaced, bass first. */
|
|
32
|
+
fn rzAudioBandCount() -> i32 { return i32(_rzAudio[1]); }
|
|
33
|
+
|
|
34
|
+
fn _rzAudioFrameAt(offset: f32) -> i32 {
|
|
35
|
+
let frames = i32(_rzAudio[0]);
|
|
36
|
+
let t = _rzAudio[3] + offset;
|
|
37
|
+
return clamp(i32(t / max(_rzAudio[2], 1e-5)), 0, frames - 1);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Loudness at now + offset seconds, 0..1.
|
|
42
|
+
*
|
|
43
|
+
* The offset is what makes a WAVEFORM drawable: a column at x samples the
|
|
44
|
+
* envelope at (x - 0.5) * window seconds, and the playhead is the centre of the
|
|
45
|
+
* screen by construction. Offsets past either end clamp to the track's edges.
|
|
46
|
+
*/
|
|
47
|
+
fn rzAudioLevelAt(offset: f32) -> f32 {
|
|
48
|
+
let frames = i32(_rzAudio[0]);
|
|
49
|
+
if (frames <= 0) { return 0.0; }
|
|
50
|
+
let bands = i32(_rzAudio[1]);
|
|
51
|
+
return _rzAudio[8 + _rzAudioFrameAt(offset) * (bands + 2)];
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* The KICK track: how hard the bass is rising at now + offset, 0..1 —
|
|
56
|
+
* precomputed, so a beat-triggered effect is one comparison instead of a
|
|
57
|
+
* per-pixel history scan. Scan a short window of NEGATIVE offsets to find
|
|
58
|
+
* recent hits and age things from them (quantise the hit to the analysis grid
|
|
59
|
+
* so ages stay continuous).
|
|
60
|
+
*/
|
|
61
|
+
fn rzAudioOnsetAt(offset: f32) -> f32 {
|
|
62
|
+
let frames = i32(_rzAudio[0]);
|
|
63
|
+
if (frames <= 0) { return 0.0; }
|
|
64
|
+
let bands = i32(_rzAudio[1]);
|
|
65
|
+
return _rzAudio[8 + _rzAudioFrameAt(offset) * (bands + 2) + 1];
|
|
66
|
+
}
|
|
67
|
+
fn rzAudioOnset() -> f32 { return rzAudioOnsetAt(0.0); }
|
|
68
|
+
/** Loudness now, 0..1. */
|
|
69
|
+
fn rzAudioLevel() -> f32 { return rzAudioLevelAt(0.0); }
|
|
70
|
+
|
|
71
|
+
/** Band i at now + offset seconds, 0..1. Band 0 is the deepest bass. */
|
|
72
|
+
fn rzAudioBandAt(i: i32, offset: f32) -> f32 {
|
|
73
|
+
let frames = i32(_rzAudio[0]);
|
|
74
|
+
if (frames <= 0) { return 0.0; }
|
|
75
|
+
let bands = i32(_rzAudio[1]);
|
|
76
|
+
if (i < 0 || i >= bands) { return 0.0; }
|
|
77
|
+
return _rzAudio[8 + _rzAudioFrameAt(offset) * (bands + 2) + 2 + i];
|
|
78
|
+
}
|
|
79
|
+
/** Band i now — the visualiser call. */
|
|
80
|
+
fn rzAudioBand(i: i32) -> f32 { return rzAudioBandAt(i, 0.0); }
|
|
81
|
+
`
|
|
82
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { audioApi } from "../audio-api"
|
|
1
2
|
// Composite: HDR scene + bloom pyramid → Filmic tone map → gamma → swapchain.
|
|
2
3
|
// Bloom tint/intensity applied at combine (EEVEE treats them as combine-stage params, not prefilter).
|
|
3
4
|
//
|
|
@@ -162,6 +163,16 @@ override APPLY_GAMMA: bool = true;
|
|
|
162
163
|
// vec4 slots: [0 .. 11] four subjects, three each (root+valid, hip, bounds);
|
|
163
164
|
// then MAX_ANCHORS × four subjects, three each (pos+valid, vel, fwd).
|
|
164
165
|
@group(0) @binding(11) var<storage, read> _rzCast: array<vec4f>;
|
|
166
|
+
// The FIELD LAYER: the user's background/foreground mounts, rendered at half
|
|
167
|
+
// resolution in their own pass (see buildFieldShader) and sampled here. Field
|
|
168
|
+
// effects are glows, fog, shafts, gradients — low-frequency by nature — and
|
|
169
|
+
// running them per quarter-pixel was the single largest per-frame cost an
|
|
170
|
+
// effect could add. Bilinear upsampling is invisible at that frequency.
|
|
171
|
+
@group(0) @binding(15) var fieldBgTex: texture_2d<f32>;
|
|
172
|
+
@group(0) @binding(16) var fieldFgTex: texture_2d<f32>;
|
|
173
|
+
// Geometry ribbons, max-blended in their own layer (trails.ts). Composited in
|
|
174
|
+
// display space below, where the fullscreen ribbon effects always ran.
|
|
175
|
+
@group(0) @binding(12) var trailTex: texture_2d<f32>;
|
|
165
176
|
|
|
166
177
|
// Must match FILMIC_LUT_WIDTH in engine.ts (bakeFilmicLut).
|
|
167
178
|
const FILMIC_LUT_W: f32 = 256.0;
|
|
@@ -302,6 +313,11 @@ fn rzProject(p: vec3f) -> vec3f {
|
|
|
302
313
|
return vec3f(ndc * 0.5 + 0.5, z);
|
|
303
314
|
}
|
|
304
315
|
|
|
316
|
+
fn rzCamPos() -> vec3f { return rzCameraPos(); }
|
|
317
|
+
fn rzCameraRight() -> vec3f { return viewU[3].xyz; }
|
|
318
|
+
fn rzCameraUp() -> vec3f { return viewU[4].xyz; }
|
|
319
|
+
fn rzCameraForward() -> vec3f { return viewU[5].xyz; }
|
|
320
|
+
|
|
305
321
|
/** A character, as much of one as a shader needs. */
|
|
306
322
|
struct RzSubject {
|
|
307
323
|
/** On the FLOOR, under the body — where a ring or a magic circle belongs. */
|
|
@@ -577,6 +593,11 @@ const COMPOSITE_BODY = /* wgsl */ `
|
|
|
577
593
|
// expression, because the foreground mount composites onto it.
|
|
578
594
|
var outRgb = disp * sceneAlpha + bgPm * (1.0 - sceneAlpha);
|
|
579
595
|
var outA = sceneAlpha + bgA * (1.0 - sceneAlpha);
|
|
596
|
+
// The trail layer, straight-alpha over the tone-mapped frame — the author's
|
|
597
|
+
// colours verbatim, never through AgX.
|
|
598
|
+
let trailFx = textureLoad(trailTex, vec2i(fragCoord.xy), 0);
|
|
599
|
+
outRgb = trailFx.rgb * trailFx.a + outRgb * (1.0 - trailFx.a);
|
|
600
|
+
outA = trailFx.a + outA * (1.0 - trailFx.a);
|
|
580
601
|
FOREGROUND_CALL
|
|
581
602
|
return vec4f(outRgb, outA);
|
|
582
603
|
}
|
|
@@ -587,8 +608,7 @@ const COMPOSITE_BODY = /* wgsl */ `
|
|
|
587
608
|
// OVER onto the base layer. No `if` around it: the pipeline is rebuilt per
|
|
588
609
|
// effect, so this text only exists in variants whose WGSL defines background().
|
|
589
610
|
const BACKGROUND_CALL = /* wgsl */ `
|
|
590
|
-
let
|
|
591
|
-
let bgFx = clamp(background(dir, bgUv, viewU[6].x), vec4f(0.0), vec4f(1.0));
|
|
611
|
+
let bgFx = clamp(textureSampleLevel(fieldBgTex, bloomSamp, fragCoord.xy / fullSz, 0.0), vec4f(0.0), vec4f(1.0));
|
|
592
612
|
bgPm = bgFx.rgb * bgFx.a + bgPm * (1.0 - bgFx.a);
|
|
593
613
|
bgA = bgFx.a + bgA * (1.0 - bgFx.a);
|
|
594
614
|
`
|
|
@@ -597,12 +617,7 @@ const BACKGROUND_CALL = /* wgsl */ `
|
|
|
597
617
|
// base. Ungated by design: a foreground runs at every pixel, including the ones
|
|
598
618
|
// the model covers, because covering them is the point.
|
|
599
619
|
const FOREGROUND_CALL = /* wgsl */ `
|
|
600
|
-
let
|
|
601
|
-
// The scene's own depth, so the effect can tell what is in front of it: a
|
|
602
|
-
// petal compares its distance against this and lets the model take the pixel,
|
|
603
|
-
// and fog's alpha is nothing but a function of it. Pixels the scene never drew
|
|
604
|
-
// read the far plane, so distance fog closes over the backdrop too.
|
|
605
|
-
let fgFx = clamp(foreground(dir, fgUv, viewU[6].x, linearDepth(coord)), vec4f(0.0), vec4f(1.0));
|
|
620
|
+
let fgFx = clamp(textureSampleLevel(fieldFgTex, bloomSamp, fragCoord.xy / fullSz, 0.0), vec4f(0.0), vec4f(1.0));
|
|
606
621
|
outRgb = fgFx.rgb * fgFx.a + outRgb * (1.0 - fgFx.a);
|
|
607
622
|
outA = fgFx.a + outA * (1.0 - fgFx.a);
|
|
608
623
|
`
|
|
@@ -629,24 +644,74 @@ const USES_DERIVATIVES = /\b(?:fwidth|dpdx|dpdy)(?:Fine|Coarse)?\s*\(/
|
|
|
629
644
|
function backgroundCondition(effect?: CompositeEffectSource | null): string {
|
|
630
645
|
// sceneAlpha, not alpha: the bokeh gather spreads coverage, so a pixel the
|
|
631
646
|
// sharp scene fully covered can end up needing background behind its blur.
|
|
647
|
+
// (The old derivative carve-out is gone with the inline user code: the field
|
|
648
|
+
// pass runs the whole quad, which is uniform control flow by construction.)
|
|
632
649
|
const coverage = "sceneAlpha < 0.999"
|
|
633
650
|
if (!effect?.hasBackground) return `bg.w > 1.5 && ${coverage}`
|
|
634
|
-
return
|
|
651
|
+
return coverage
|
|
635
652
|
}
|
|
636
653
|
|
|
637
654
|
export function buildCompositeShader(effect?: CompositeEffectSource | null): string {
|
|
638
655
|
const body = COMPOSITE_BODY.replace("BACKGROUND_COND", backgroundCondition(effect))
|
|
639
656
|
.replace("BACKGROUND_CALL", effect?.hasBackground ? BACKGROUND_CALL.trim() : "")
|
|
640
657
|
.replace("FOREGROUND_CALL", effect?.hasForeground ? FOREGROUND_CALL.trim() : "")
|
|
641
|
-
|
|
658
|
+
// The composite is STATIC either way now: the user's code compiles in the
|
|
659
|
+
// field module alone, and the composite only decides whether to sample it.
|
|
660
|
+
return COMPOSITE_HEAD + audioApi(0, 13) + body
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
/**
|
|
664
|
+
* The field pass: the user's background/foreground mounts at half resolution,
|
|
665
|
+
* into two rgba16f targets the composite bilinearly upsamples.
|
|
666
|
+
*
|
|
667
|
+
* The fragment reconstructs the FULL-resolution pixel it stands in for and runs
|
|
668
|
+
* the original derivation verbatim — same ndc, same ray, same uv, same depth
|
|
669
|
+
* read — so an effect cannot tell it moved; it is simply asked half as often
|
|
670
|
+
* in each direction.
|
|
671
|
+
*/
|
|
672
|
+
export function buildFieldShader(effect: CompositeEffectSource): string {
|
|
673
|
+
const bgLine = effect.hasBackground
|
|
674
|
+
? "out.bg = clamp(background(dir, uv, viewU[6].x), vec4f(0.0), vec4f(1.0));"
|
|
675
|
+
: ""
|
|
676
|
+
const fgLine = effect.hasForeground
|
|
677
|
+
? "out.fg = clamp(foreground(dir, uv, viewU[6].x, linearDepth(vec2<i32>(min(fx, fullSz - 1.0)))), vec4f(0.0), vec4f(1.0));"
|
|
678
|
+
: ""
|
|
642
679
|
return (
|
|
643
680
|
COMPOSITE_HEAD +
|
|
681
|
+
audioApi(0, 13) +
|
|
644
682
|
"\n// ── user effect (setEffect) ──\n" +
|
|
645
683
|
effect.paramsDecl +
|
|
646
684
|
"\n" +
|
|
647
685
|
effect.wgsl +
|
|
648
686
|
"\n" +
|
|
649
|
-
|
|
687
|
+
/* wgsl */ `
|
|
688
|
+
@group(0) @binding(14) var<uniform> fieldU: vec4f;
|
|
689
|
+
|
|
690
|
+
@vertex fn fieldVs(@builtin(vertex_index) vi: u32) -> @builtin(position) vec4f {
|
|
691
|
+
let x = f32((vi & 1u) << 2u) - 1.0;
|
|
692
|
+
let y = f32((vi & 2u) << 1u) - 1.0;
|
|
693
|
+
return vec4f(x, y, 0.0, 1.0);
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
struct FieldOut {
|
|
697
|
+
@location(0) bg: vec4f,
|
|
698
|
+
@location(1) fg: vec4f,
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
@fragment fn fieldFs(@builtin(position) fragCoord: vec4f) -> FieldOut {
|
|
702
|
+
let fullSz = fieldU.zw;
|
|
703
|
+
let fx = fragCoord.xy * (fullSz / max(fieldU.xy, vec2f(1.0)));
|
|
704
|
+
let ndc = vec2f(fx.x / fullSz.x * 2.0 - 1.0, 1.0 - fx.y / fullSz.y * 2.0);
|
|
705
|
+
let dir = normalize(viewU[5].xyz + ndc.x * viewU[3].w * viewU[3].xyz + ndc.y * viewU[4].w * viewU[4].xyz);
|
|
706
|
+
let uv = vec2f(fx.x / fullSz.x, 1.0 - fx.y / fullSz.y);
|
|
707
|
+
var out: FieldOut;
|
|
708
|
+
out.bg = vec4f(0.0);
|
|
709
|
+
out.fg = vec4f(0.0);
|
|
710
|
+
${bgLine}
|
|
711
|
+
${fgLine}
|
|
712
|
+
return out;
|
|
713
|
+
}
|
|
714
|
+
`
|
|
650
715
|
)
|
|
651
716
|
}
|
|
652
717
|
|
|
@@ -0,0 +1,398 @@
|
|
|
1
|
+
import { audioApi } from "../audio-api"
|
|
2
|
+
// GPU particles for user effects: a compute step and an instanced quad draw.
|
|
3
|
+
//
|
|
4
|
+
// Its own shader MODULE rather than more source spliced into composite.ts, for
|
|
5
|
+
// three reasons that all point the same way. Cost: a fullscreen effect pays for
|
|
6
|
+
// every pixel on screen whether it drew anything there or not, so a ribbon walked
|
|
7
|
+
// as a field cost O(pixels × samples) where the same picture as geometry costs
|
|
8
|
+
// O(segments) — the difference between one effect and a scene full of them.
|
|
9
|
+
// Occlusion: geometry drawn inside the scene pass is depth-tested, so a particle
|
|
10
|
+
// behind the character is simply hidden, which a composited field can only fake.
|
|
11
|
+
// And names: separate modules cannot collide, so two effects may both define
|
|
12
|
+
// `hash21` and never meet — which is what makes running several at once safe.
|
|
13
|
+
//
|
|
14
|
+
// The author writes three functions and the engine owns the loop. That split is
|
|
15
|
+
// deliberate: spawning, ageing, recycling, billboarding and sorting are the same
|
|
16
|
+
// in every particle effect ever written, and an author who has to re-derive them
|
|
17
|
+
// gets them subtly wrong in a way that only shows up on someone else's machine.
|
|
18
|
+
|
|
19
|
+
/** Where the cast/trail history sits in the shared storage buffer. */
|
|
20
|
+
export type CastLayout = { subjects: number; samples: number; base: number; trailBase: number; slots: number }
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The trail accessors, in the PARTICLE module.
|
|
24
|
+
*
|
|
25
|
+
* Sparks are the reason: the original hand ribbon shed sparks along its path,
|
|
26
|
+
* and as real particles they need to SPAWN on that path — which means
|
|
27
|
+
* particleInit reading the same recorded history the trail draws from. One
|
|
28
|
+
* effect file, two mounts, one buffer.
|
|
29
|
+
*/
|
|
30
|
+
function castApi(cast: CastLayout): string {
|
|
31
|
+
return `
|
|
32
|
+
const RZ_SUBJECTS: i32 = ${cast.subjects};
|
|
33
|
+
const RZ_SAMPLES: i32 = ${cast.samples};
|
|
34
|
+
const RZ_TRAIL_SLOTS: i32 = ${cast.slots};
|
|
35
|
+
fn rzSubjectCount() -> i32 {
|
|
36
|
+
var n = 0;
|
|
37
|
+
for (var i = 0; i < RZ_SUBJECTS; i++) {
|
|
38
|
+
if (_rzCast[i * 3 + 2].w > 0.0) { n = i + 1; }
|
|
39
|
+
}
|
|
40
|
+
return n;
|
|
41
|
+
}
|
|
42
|
+
fn rzTrailCount(subject: i32, slot: i32) -> i32 {
|
|
43
|
+
if (subject < 0 || subject >= RZ_SUBJECTS || slot < 0 || slot >= RZ_TRAIL_SLOTS) { return 0; }
|
|
44
|
+
return i32(_rzCast[${cast.base} + (slot * RZ_SUBJECTS + subject) * 3 + 2].w);
|
|
45
|
+
}
|
|
46
|
+
/** Sample i of a path: xyz where it was, w how many seconds ago. i = 0 is now. */
|
|
47
|
+
fn rzTrail(subject: i32, slot: i32, i: i32) -> vec4f {
|
|
48
|
+
let n = rzTrailCount(subject, slot);
|
|
49
|
+
if (i < 0 || i >= n) { return vec4f(0.0); }
|
|
50
|
+
return _rzCast[${cast.trailBase} + (slot * RZ_SUBJECTS + subject) * RZ_SAMPLES + i];
|
|
51
|
+
}
|
|
52
|
+
`
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** How the author's quads combine with the scene. */
|
|
56
|
+
export type ParticleBlend = "alpha" | "additive"
|
|
57
|
+
|
|
58
|
+
export type ParticleSource = {
|
|
59
|
+
/** The author's WGSL verbatim. */
|
|
60
|
+
wgsl: string
|
|
61
|
+
/** Simultaneous particles. Fixed at install; the pool recycles rather than grows. */
|
|
62
|
+
count: number
|
|
63
|
+
blend: ParticleBlend
|
|
64
|
+
/** Feed the bloom pyramid. Off by default — rain should not glow. */
|
|
65
|
+
bloom: boolean
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Bytes per particle. Explicitly padded — see the struct below. */
|
|
69
|
+
export const PARTICLE_STRIDE = 48
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* The particle record, laid out by hand.
|
|
73
|
+
*
|
|
74
|
+
* `age` and `life` sit in the padding that vec3f alignment would waste anyway
|
|
75
|
+
* (a vec3f occupies 12 bytes but aligns the next field to 16), so the struct is
|
|
76
|
+
* 48 bytes rather than the 64 a naive ordering costs. At 4096 particles that is
|
|
77
|
+
* 192KB instead of 256KB, and it is read every frame by both stages.
|
|
78
|
+
*
|
|
79
|
+
* `life <= 0` means "not alive" and is what the pool checks to recycle a slot,
|
|
80
|
+
* so a freshly zeroed buffer is entirely dead and every particle is born on the
|
|
81
|
+
* first step rather than needing a separate seeding pass.
|
|
82
|
+
*/
|
|
83
|
+
const PARTICLE_STRUCT = /* wgsl */ `
|
|
84
|
+
struct Particle {
|
|
85
|
+
pos: vec3f,
|
|
86
|
+
age: f32,
|
|
87
|
+
vel: vec3f,
|
|
88
|
+
life: f32,
|
|
89
|
+
size: f32,
|
|
90
|
+
rot: f32,
|
|
91
|
+
seed: f32,
|
|
92
|
+
// Aspect along the direction of travel. 1 or less is a square billboard; a
|
|
93
|
+
// raindrop is 10 or 20. Zero-initialised, so an effect that never sets it gets
|
|
94
|
+
// the square it expects.
|
|
95
|
+
stretch: f32,
|
|
96
|
+
}
|
|
97
|
+
`
|
|
98
|
+
|
|
99
|
+
const CAMERA_STRUCT = /* wgsl */ `
|
|
100
|
+
struct CameraU {
|
|
101
|
+
view: mat4x4f,
|
|
102
|
+
proj: mat4x4f,
|
|
103
|
+
camPos: vec3f,
|
|
104
|
+
targetHeight: f32,
|
|
105
|
+
}
|
|
106
|
+
`
|
|
107
|
+
|
|
108
|
+
const PARTICLE_UNIFORMS = /* wgsl */ `
|
|
109
|
+
struct ParticleU {
|
|
110
|
+
time: f32,
|
|
111
|
+
dt: f32,
|
|
112
|
+
count: u32,
|
|
113
|
+
frame: u32,
|
|
114
|
+
}
|
|
115
|
+
`
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* The shared prelude, everything `rz`-prefixed.
|
|
119
|
+
*
|
|
120
|
+
* Not convenience — correctness. Every effect written against the old contract
|
|
121
|
+
* re-derived its own hash and its own falloff, which is duplicated code and
|
|
122
|
+
* duplicated bugs; `rzFalloff` in particular has COMPACT SUPPORT (it reaches
|
|
123
|
+
* exactly zero at r), because an exponential glow that never quite reaches zero
|
|
124
|
+
* has to be culled somewhere, and culling it wherever it "looks close enough"
|
|
125
|
+
* is what put a visible hard edge on the first halo effect.
|
|
126
|
+
*/
|
|
127
|
+
const PRELUDE = /* wgsl */ `
|
|
128
|
+
fn rzHash11(x: f32) -> f32 {
|
|
129
|
+
var p = fract(x * 0.1031);
|
|
130
|
+
p = p * (p + 33.33);
|
|
131
|
+
return fract(p * (p + p));
|
|
132
|
+
}
|
|
133
|
+
fn rzHash21(p: vec2f) -> f32 {
|
|
134
|
+
var p3 = fract(vec3f(p.x, p.y, p.x) * 0.1031);
|
|
135
|
+
p3 = p3 + dot(p3, p3.yzx + 33.33);
|
|
136
|
+
return fract((p3.x + p3.y) * p3.z);
|
|
137
|
+
}
|
|
138
|
+
fn rzHash31(p: vec3f) -> f32 {
|
|
139
|
+
var p3 = fract(p * 0.1031);
|
|
140
|
+
p3 = p3 + dot(p3, p3.zyx + 31.32);
|
|
141
|
+
return fract((p3.x + p3.y) * p3.z);
|
|
142
|
+
}
|
|
143
|
+
/** Three independent randoms from one seed — the usual need when spawning. */
|
|
144
|
+
fn rzHash13(x: f32) -> vec3f {
|
|
145
|
+
return vec3f(rzHash11(x), rzHash11(x + 17.13), rzHash11(x + 41.71));
|
|
146
|
+
}
|
|
147
|
+
fn rzValueNoise(p: vec3f) -> f32 {
|
|
148
|
+
let i = floor(p);
|
|
149
|
+
let f = fract(p);
|
|
150
|
+
let u = f * f * (3.0 - 2.0 * f);
|
|
151
|
+
let n000 = rzHash31(i + vec3f(0.0, 0.0, 0.0));
|
|
152
|
+
let n100 = rzHash31(i + vec3f(1.0, 0.0, 0.0));
|
|
153
|
+
let n010 = rzHash31(i + vec3f(0.0, 1.0, 0.0));
|
|
154
|
+
let n110 = rzHash31(i + vec3f(1.0, 1.0, 0.0));
|
|
155
|
+
let n001 = rzHash31(i + vec3f(0.0, 0.0, 1.0));
|
|
156
|
+
let n101 = rzHash31(i + vec3f(1.0, 0.0, 1.0));
|
|
157
|
+
let n011 = rzHash31(i + vec3f(0.0, 1.0, 1.0));
|
|
158
|
+
let n111 = rzHash31(i + vec3f(1.0, 1.0, 1.0));
|
|
159
|
+
let x00 = mix(n000, n100, u.x);
|
|
160
|
+
let x10 = mix(n010, n110, u.x);
|
|
161
|
+
let x01 = mix(n001, n101, u.x);
|
|
162
|
+
let x11 = mix(n011, n111, u.x);
|
|
163
|
+
return mix(mix(x00, x10, u.y), mix(x01, x11, u.y), u.z);
|
|
164
|
+
}
|
|
165
|
+
/** Divergence-free flow — the standard drifting-air force. Snow and mist want this. */
|
|
166
|
+
fn rzCurlNoise(p: vec3f) -> vec3f {
|
|
167
|
+
let e = 0.1;
|
|
168
|
+
let dx = vec3f(e, 0.0, 0.0);
|
|
169
|
+
let dy = vec3f(0.0, e, 0.0);
|
|
170
|
+
let dz = vec3f(0.0, 0.0, e);
|
|
171
|
+
let x0 = rzValueNoise(p - dx); let x1 = rzValueNoise(p + dx);
|
|
172
|
+
let y0 = rzValueNoise(p - dy); let y1 = rzValueNoise(p + dy);
|
|
173
|
+
let z0 = rzValueNoise(p - dz); let z1 = rzValueNoise(p + dz);
|
|
174
|
+
return normalize(vec3f((y1 - y0) - (z1 - z0), (z1 - z0) - (x1 - x0), (x1 - x0) - (y1 - y0)) + vec3f(1e-6));
|
|
175
|
+
}
|
|
176
|
+
/** Compact-support falloff: 1 at the centre, exactly 0 at r, smooth between. */
|
|
177
|
+
fn rzFalloff(d: f32, r: f32) -> f32 {
|
|
178
|
+
let x = clamp(d / max(r, 1e-6), 0.0, 1.0);
|
|
179
|
+
let f = 1.0 - x;
|
|
180
|
+
return f * f * f;
|
|
181
|
+
}
|
|
182
|
+
fn rzTime() -> f32 { return pu.time; }
|
|
183
|
+
fn rzViewportHeight() -> f32 { return cam.targetHeight; }
|
|
184
|
+
fn rzCameraPos() -> vec3f { return cam.camPos; }
|
|
185
|
+
fn rzCameraRight() -> vec3f { return vec3f(cam.view[0][0], cam.view[1][0], cam.view[2][0]); }
|
|
186
|
+
fn rzCameraUp() -> vec3f { return vec3f(cam.view[0][1], cam.view[1][1], cam.view[2][1]); }
|
|
187
|
+
fn rzCameraForward() -> vec3f { return vec3f(cam.view[0][2], cam.view[1][2], cam.view[2][2]); }
|
|
188
|
+
/** World point → (uv, view distance), same contract as the field mounts' rzProject. */
|
|
189
|
+
fn rzProject(p: vec3f) -> vec3f {
|
|
190
|
+
let clip = cam.proj * cam.view * vec4f(p, 1.0);
|
|
191
|
+
let w = max(clip.w, 1e-4);
|
|
192
|
+
return vec3f(clip.xy / w * 0.5 + 0.5, clip.w);
|
|
193
|
+
}
|
|
194
|
+
fn rzDt() -> f32 { return pu.dt; }
|
|
195
|
+
fn rzCamPos() -> vec3f { return cam.camPos; }
|
|
196
|
+
`
|
|
197
|
+
|
|
198
|
+
/** `// @particles 4096` — how many live at once. */
|
|
199
|
+
export function parseParticleCount(wgsl: string, max: number): number {
|
|
200
|
+
const m = /^\s*\/\/\s*@particles\s+(\d+)\s*$/m.exec(wgsl)
|
|
201
|
+
if (!m) return 0
|
|
202
|
+
// Clamped rather than rejected: an author asking for a million gets the most
|
|
203
|
+
// the engine will give and a scene that still runs, which is a better failure
|
|
204
|
+
// than a compile error naming a number they had no way to know.
|
|
205
|
+
return Math.max(1, Math.min(max, parseInt(m[1], 10)))
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** `// @bloom` — opt in to the bloom pyramid. Sparks want it; rain does not. */
|
|
209
|
+
export function parseParticleBloom(wgsl: string): boolean {
|
|
210
|
+
return /^\s*\/\/\s*@bloom\s*$/m.test(wgsl)
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** `// @blend additive` — default is straight alpha. */
|
|
214
|
+
export function parseParticleBlend(wgsl: string): ParticleBlend {
|
|
215
|
+
return /^\s*\/\/\s*@blend\s+additive\s*$/m.test(wgsl) ? "additive" : "alpha"
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** Does the source define the particle contract? All three are required together. */
|
|
219
|
+
export function particleEntryPoints(wgsl: string): { init: boolean; step: boolean; shade: boolean } {
|
|
220
|
+
return {
|
|
221
|
+
init: /\bfn\s+particleInit\s*\(/.test(wgsl),
|
|
222
|
+
step: /\bfn\s+particleStep\s*\(/.test(wgsl),
|
|
223
|
+
shade: /\bfn\s+particleShade\s*\(/.test(wgsl),
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Spawn, age, recycle.
|
|
229
|
+
*
|
|
230
|
+
* One kernel for both birth and update, because a dead slot and a new particle
|
|
231
|
+
* are the same write — a pool that recycles has no allocation and therefore no
|
|
232
|
+
* spawn-rate bookkeeping to get wrong. The cost is that lifetimes are staggered
|
|
233
|
+
* only by whatever the author randomises in `particleInit`, which for rain and
|
|
234
|
+
* snow is exactly right, and for a burst is what the age offset is for.
|
|
235
|
+
*/
|
|
236
|
+
export function buildParticleComputeShader(src: ParticleSource, cast: CastLayout): string {
|
|
237
|
+
return (
|
|
238
|
+
PARTICLE_STRUCT +
|
|
239
|
+
CAMERA_STRUCT +
|
|
240
|
+
PARTICLE_UNIFORMS +
|
|
241
|
+
`
|
|
242
|
+
@group(0) @binding(0) var<storage, read_write> particles: array<Particle>;
|
|
243
|
+
@group(0) @binding(1) var<uniform> pu: ParticleU;
|
|
244
|
+
@group(0) @binding(2) var<uniform> cam: CameraU;
|
|
245
|
+
@group(0) @binding(3) var<storage, read> _rzCast: array<vec4f>;
|
|
246
|
+
` +
|
|
247
|
+
castApi(cast) +
|
|
248
|
+
audioApi(0, 4) +
|
|
249
|
+
PRELUDE +
|
|
250
|
+
"\n// ── user effect ──\n" +
|
|
251
|
+
src.wgsl +
|
|
252
|
+
/* wgsl */ `
|
|
253
|
+
@compute @workgroup_size(64)
|
|
254
|
+
fn main(@builtin(global_invocation_id) gid: vec3u) {
|
|
255
|
+
let i = gid.x;
|
|
256
|
+
if (i >= pu.count) { return; }
|
|
257
|
+
var p = particles[i];
|
|
258
|
+
if (p.life <= 0.0 || p.age >= p.life) {
|
|
259
|
+
// The seed is stable per SLOT and per generation, so a particle looks the
|
|
260
|
+
// same every time the scene is replayed at the same moment — which is what
|
|
261
|
+
// keeps an exported video identical to the preview.
|
|
262
|
+
let generation = floor(pu.time * 0.37) + f32(i) * 0.618;
|
|
263
|
+
p = particleInit(i, rzHash11(generation));
|
|
264
|
+
// age is NOT reset here. WGSL zero-initialises a var, so an author who
|
|
265
|
+
// ignores it starts at zero anyway — while one who sets it to a fraction of
|
|
266
|
+
// its life staggers the pool, which is the difference between snow and a
|
|
267
|
+
// pulse of snow arriving all at once every few seconds.
|
|
268
|
+
} else {
|
|
269
|
+
p = particleStep(p, pu.dt);
|
|
270
|
+
p.age = p.age + pu.dt;
|
|
271
|
+
}
|
|
272
|
+
particles[i] = p;
|
|
273
|
+
}
|
|
274
|
+
`
|
|
275
|
+
)
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* One camera-facing quad per live particle.
|
|
280
|
+
*
|
|
281
|
+
* Six vertices, no index or vertex buffer: the corners are derived from
|
|
282
|
+
* `vertex_index` and the particle is read from storage by `instance_index`, so a
|
|
283
|
+
* draw is `draw(6, count)` and there is nothing to upload per frame. The billboard
|
|
284
|
+
* basis comes from the VIEW matrix's rows rather than from a look-at, which keeps
|
|
285
|
+
* the quad square on screen no matter where the camera rolls.
|
|
286
|
+
*
|
|
287
|
+
* A dead particle collapses to a degenerate quad instead of being culled on the
|
|
288
|
+
* CPU — the alternative is a compacted draw list, which costs a prefix sum and a
|
|
289
|
+
* readback every frame to save vertices the rasteriser was going to reject anyway.
|
|
290
|
+
*/
|
|
291
|
+
export function buildParticleRenderShader(src: ParticleSource, cast: CastLayout): string {
|
|
292
|
+
return (
|
|
293
|
+
`override BLOOM: bool = ${src.bloom ? "true" : "false"};
|
|
294
|
+
override ADDITIVE: bool = ${src.blend === "additive" ? "true" : "false"};\n` +
|
|
295
|
+
PARTICLE_STRUCT +
|
|
296
|
+
CAMERA_STRUCT +
|
|
297
|
+
PARTICLE_UNIFORMS +
|
|
298
|
+
`
|
|
299
|
+
@group(0) @binding(0) var<storage, read> particles: array<Particle>;
|
|
300
|
+
@group(0) @binding(1) var<uniform> pu: ParticleU;
|
|
301
|
+
@group(0) @binding(2) var<uniform> cam: CameraU;
|
|
302
|
+
@group(0) @binding(3) var<storage, read> _rzCast: array<vec4f>;
|
|
303
|
+
` +
|
|
304
|
+
castApi(cast) +
|
|
305
|
+
audioApi(0, 4) +
|
|
306
|
+
PRELUDE +
|
|
307
|
+
"\n// ── user effect ──\n" +
|
|
308
|
+
src.wgsl +
|
|
309
|
+
/* wgsl */ `
|
|
310
|
+
struct VSOut {
|
|
311
|
+
@builtin(position) clip: vec4f,
|
|
312
|
+
@location(0) uv: vec2f,
|
|
313
|
+
@location(1) @interpolate(flat) id: u32,
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
@vertex
|
|
317
|
+
fn vs(@builtin(vertex_index) vi: u32, @builtin(instance_index) ii: u32) -> VSOut {
|
|
318
|
+
var out: VSOut;
|
|
319
|
+
out.id = ii;
|
|
320
|
+
let p = particles[ii];
|
|
321
|
+
// Two triangles, corners in the order 0,1,2, 2,1,3.
|
|
322
|
+
let quad = array<vec2f, 6>(
|
|
323
|
+
vec2f(-1.0, -1.0), vec2f(1.0, -1.0), vec2f(-1.0, 1.0),
|
|
324
|
+
vec2f(-1.0, 1.0), vec2f(1.0, -1.0), vec2f(1.0, 1.0),
|
|
325
|
+
);
|
|
326
|
+
let c = quad[vi];
|
|
327
|
+
out.uv = c * 0.5 + 0.5;
|
|
328
|
+
if (p.life <= 0.0) {
|
|
329
|
+
// Degenerate: off the near plane, rasterises nothing.
|
|
330
|
+
out.clip = vec4f(0.0, 0.0, -2.0, 1.0);
|
|
331
|
+
return out;
|
|
332
|
+
}
|
|
333
|
+
let right = vec3f(cam.view[0][0], cam.view[1][0], cam.view[2][0]);
|
|
334
|
+
let up = vec3f(cam.view[0][1], cam.view[1][1], cam.view[2][1]);
|
|
335
|
+
let s = sin(p.rot);
|
|
336
|
+
let k = cos(p.rot);
|
|
337
|
+
var r = vec2f(c.x * k - c.y * s, c.x * s + c.y * k);
|
|
338
|
+
// Stretched along the direction of travel ON SCREEN — which is not the world
|
|
339
|
+
// direction once the camera is off-axis. Rain falling straight down is nearly
|
|
340
|
+
// a point when viewed from above and a long streak from the side, and taking
|
|
341
|
+
// the velocity's components in the camera's own basis is what gets both right.
|
|
342
|
+
// Rotation is ignored while stretched: the velocity IS the orientation.
|
|
343
|
+
if (p.stretch > 1.0) {
|
|
344
|
+
let vr = dot(p.vel, right);
|
|
345
|
+
let vu = dot(p.vel, up);
|
|
346
|
+
let vlen = length(vec2f(vr, vu));
|
|
347
|
+
if (vlen > 1e-5) {
|
|
348
|
+
let d = vec2f(vr, vu) / vlen;
|
|
349
|
+
r = vec2f(d.y, -d.x) * c.x + d * (c.y * p.stretch);
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
let world = p.pos + (right * r.x + up * r.y) * p.size;
|
|
353
|
+
out.clip = cam.proj * cam.view * vec4f(world, 1.0);
|
|
354
|
+
return out;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
struct FSOut {
|
|
358
|
+
@location(0) color: vec4f,
|
|
359
|
+
// The scene's aux target: (bloom mask, coverage). Materials write it, so a
|
|
360
|
+
// particle that skipped it would punch a hole in the mask of whatever it drew
|
|
361
|
+
// over.
|
|
362
|
+
//
|
|
363
|
+
// vec4f even though the target is rg8unorm and only .rg land: that target's
|
|
364
|
+
// blend factors reference SrcAlpha, and a fragment with no alpha channel is
|
|
365
|
+
// rejected outright — "reading alpha but it is missing from fragment output".
|
|
366
|
+
// The material shaders declare vec4f here for the same reason.
|
|
367
|
+
@location(1) mask: vec4f,
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
@fragment
|
|
371
|
+
fn fs(in: VSOut) -> FSOut {
|
|
372
|
+
let p = particles[in.id];
|
|
373
|
+
let c = particleShade(p, in.uv);
|
|
374
|
+
if (c.a <= 0.0) { discard; }
|
|
375
|
+
var out: FSOut;
|
|
376
|
+
// PREMULTIPLIED: the scene's colour target blends with srcFactor \"one\", so a
|
|
377
|
+
// straight-alpha fragment would come out over-bright wherever it is
|
|
378
|
+
// translucent — which is most of a soft particle.
|
|
379
|
+
out.color = vec4f(c.rgb * c.a, c.a);
|
|
380
|
+
// The mask's OPERATOR must match the colour's, or the composite invents bands.
|
|
381
|
+
//
|
|
382
|
+
// The composite divides the HDR colour by this coverage to un-premultiply
|
|
383
|
+
// before tone mapping. An ALPHA effect writes (gate, 1.0) and lets the
|
|
384
|
+
// src-alpha blend make alpha-over coverage, exactly as the materials do. An
|
|
385
|
+
// ADDITIVE effect's pipeline blends this target with factor ONE instead, and
|
|
386
|
+
// writes its values directly — coverage SUMS like the colour does, so
|
|
387
|
+
// Σ(rgb·a)/Σa returns the true colour even where the effect overlaps itself.
|
|
388
|
+
// With summed colour over alpha-over coverage, every self-overlap divided into
|
|
389
|
+
// a bright white bar — visible only over the background, because the model's
|
|
390
|
+
// own coverage is already 1 there and the divide is a no-op. That mismatch,
|
|
391
|
+
// not geometry, was the banding that survived every geometry fix.
|
|
392
|
+
let mg = select(vec2f(select(0.0, 1.0, BLOOM), 1.0), vec2f(select(0.0, c.a, BLOOM), c.a), ADDITIVE);
|
|
393
|
+
out.mask = vec4f(mg.x, mg.y, 0.0, c.a);
|
|
394
|
+
return out;
|
|
395
|
+
}
|
|
396
|
+
`
|
|
397
|
+
)
|
|
398
|
+
}
|