reze-engine 0.64.0 → 0.65.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.
Files changed (56) hide show
  1. package/dist/engine.d.ts +146 -54
  2. package/dist/engine.d.ts.map +1 -1
  3. package/dist/engine.js +734 -582
  4. package/dist/graph/slots.d.ts.map +1 -1
  5. package/dist/graph/slots.js +7 -1
  6. package/dist/index.d.ts +1 -1
  7. package/dist/index.d.ts.map +1 -1
  8. package/dist/model.d.ts +10 -1
  9. package/dist/model.d.ts.map +1 -1
  10. package/dist/model.js +15 -1
  11. package/dist/outline-normals.d.ts +12 -0
  12. package/dist/outline-normals.d.ts.map +1 -0
  13. package/dist/outline-normals.js +100 -0
  14. package/dist/pmx-loader.d.ts +3 -0
  15. package/dist/pmx-loader.d.ts.map +1 -1
  16. package/dist/pmx-loader.js +7 -2
  17. package/dist/shaders/materials/common.d.ts.map +1 -1
  18. package/dist/shaders/materials/common.js +4 -2
  19. package/dist/shaders/passes/bloom.d.ts +4 -3
  20. package/dist/shaders/passes/bloom.d.ts.map +1 -1
  21. package/dist/shaders/passes/bloom.js +58 -87
  22. package/dist/shaders/passes/composite.d.ts.map +1 -1
  23. package/dist/shaders/passes/composite.js +118 -72
  24. package/dist/shaders/passes/mirror.d.ts +18 -12
  25. package/dist/shaders/passes/mirror.d.ts.map +1 -1
  26. package/dist/shaders/passes/mirror.js +59 -13
  27. package/dist/shaders/passes/outline.d.ts.map +1 -1
  28. package/dist/shaders/passes/outline.js +71 -14
  29. package/dist/unity/host.d.ts +15 -0
  30. package/dist/unity/host.d.ts.map +1 -1
  31. package/dist/unity/host.js +103 -63
  32. package/dist/unity/looks.d.ts +5 -0
  33. package/dist/unity/looks.d.ts.map +1 -1
  34. package/dist/unity/looks.js +19 -2
  35. package/dist/unity/skin.d.ts.map +1 -1
  36. package/dist/unity/skin.js +5 -2
  37. package/dist/unity/stage.d.ts +7 -0
  38. package/dist/unity/stage.d.ts.map +1 -1
  39. package/dist/unity/stage.js +37 -2
  40. package/package.json +1 -1
  41. package/src/engine.ts +1042 -798
  42. package/src/graph/slots.ts +7 -1
  43. package/src/index.ts +1 -0
  44. package/src/model.ts +18 -1
  45. package/src/outline-normals.ts +110 -0
  46. package/src/pmx-loader.ts +8 -2
  47. package/src/shaders/materials/common.ts +4 -2
  48. package/src/shaders/passes/bloom.ts +59 -87
  49. package/src/shaders/passes/composite.ts +118 -72
  50. package/src/shaders/passes/mirror.ts +60 -13
  51. package/src/shaders/passes/outline.ts +71 -14
  52. package/src/unity/host.ts +57 -14
  53. package/src/unity/looks.ts +21 -2
  54. package/src/unity/skin.ts +5 -2
  55. package/src/unity/stage.ts +38 -2
  56. package/src/shaders/agx-lut.ts +0 -30
@@ -191,7 +191,13 @@ function epilogue(renderClass: RenderClass, alphaMode: AlphaMode, hasOpacity: bo
191
191
  // painted sheet — is Blender's Emission shader, which no lamp reaches; adding
192
192
  // the lamps to it turned X340's floor shadow, a white-RGB picture at a soft
193
193
  // alpha, into a glowing disc under the spot above it.
194
- const LIT = takesLight ? ` + rzLightsDiffuseOnce(input.worldPos, n) * albedo` : ""
194
+ //
195
+ // The model's fill (Engine.setModelFill) rides here too, as brightness: the
196
+ // surface colour times the fill, on every pixel of her alike. In the graph's
197
+ // ambient an NPR ramp would read it as LIGHT — sliding it would move her
198
+ // shadows, and under a hard step flip whole regions at once. After the graph
199
+ // it lifts her evenly and leaves every shadow where it is.
200
+ const LIT = takesLight ? ` + (rzLightsDiffuseOnce(input.worldPos, n) + objectLight.fill.rgb) * albedo` : ""
195
201
 
196
202
  const ALBEDO = ` let albedo = tex_color;
197
203
  `
package/src/index.ts CHANGED
@@ -14,6 +14,7 @@ export {
14
14
  type BloomOptions,
15
15
  type DepthOfFieldOptions,
16
16
  type ViewTransformOptions,
17
+ type ViewTransformName,
17
18
  type LoadModelFromFilesOptions,
18
19
  type MaterialPreset,
19
20
  type MaterialPresetMap,
package/src/model.ts CHANGED
@@ -3,6 +3,7 @@ import { Engine } from "./engine"
3
3
  import { joinAssetPath, type AssetReader } from "./asset-reader"
4
4
  import { Rigidbody, Joint } from "./physics"
5
5
  import { IKSolverSystem } from "./ik-solver"
6
+ import { buildOutlineVertices } from "./outline-normals"
6
7
  import { VMDLoader, type VMDKeyFrame } from "./vmd-loader"
7
8
  import { VMDWriter, type VmdTrackSelection } from "./vmd-writer"
8
9
  import {
@@ -564,8 +565,10 @@ export class Model {
564
565
  morphing: Morphing,
565
566
  rigidbodies: Rigidbody[] = [],
566
567
  joints: Joint[] = [],
567
- loadWarnings: string[] = []
568
+ loadWarnings: string[] = [],
569
+ edgeScales: Float32Array | null = null
568
570
  ) {
571
+ this.edgeScales = edgeScales
569
572
  // Store base vertex data (original positions before morphing)
570
573
  this.baseVertexData = new Float32Array(vertexData)
571
574
  this.vertexData = vertexData
@@ -1001,6 +1004,20 @@ export class Model {
1001
1004
  return this.vertexData
1002
1005
  }
1003
1006
 
1007
+ /** PMX per-vertex edge scale, when the source had one. */
1008
+ private edgeScales: Float32Array | null = null
1009
+ private outlineVertices: Float32Array<ArrayBuffer> | null = null
1010
+
1011
+ /**
1012
+ * The outline hull's own vertex stream: per vertex, the smoothed rest-pose
1013
+ * normal (xyz) and the PMX edge scale (w). See outline-normals.ts. Built on
1014
+ * first ask — only models with an edge-flagged material ever ask.
1015
+ */
1016
+ getOutlineVertices(): Float32Array<ArrayBuffer> {
1017
+ this.outlineVertices ??= buildOutlineVertices(this.baseVertexData, VERTEX_STRIDE, this.indexData, this.edgeScales ?? undefined)
1018
+ return this.outlineVertices
1019
+ }
1020
+
1004
1021
  getTextures(): Texture[] {
1005
1022
  return this.textures
1006
1023
  }
@@ -0,0 +1,110 @@
1
+ // The inverted hull's own push direction, one per vertex, built once at load.
2
+ //
3
+ // The hull is the model pushed out along a normal. Pushed along the MESH's
4
+ // normals it tears wherever the mesh carries two vertices at one position —
5
+ // every hard edge and every UV seam — because the two copies go two ways and
6
+ // the gap between them shows the body through the line: the jaw, the finger
7
+ // joints, the parting in the hair. Aether Gazer bakes a smoothed normal into
8
+ // vertex colour for exactly this (Character/Debug.shader, pass "Outline"); a
9
+ // PMX has nowhere to carry one, so it is computed here instead: the normals of
10
+ // every vertex at one position, averaged, so the copies agree and the hull
11
+ // stays closed.
12
+ //
13
+ // Each vertex's vote is weighted by the corner angles it owns, which is what
14
+ // makes a hard corner come out as the corner's true bisector rather than
15
+ // leaning toward whichever side happened to be cut into more triangles.
16
+ //
17
+ // Rest pose only, as the game's is: a morph moves the positions and leaves this
18
+ // alone, and skinning turns it exactly as it turns the normal.
19
+
20
+ /** Positions closer than this (model units) are one position. */
21
+ export const OUTLINE_WELD_EPSILON = 1e-4
22
+
23
+ /**
24
+ * Smoothed outline normals, packed with the PMX per-vertex edge scale:
25
+ * xyz = unit direction, w = edge scale — four floats per vertex.
26
+ *
27
+ * @param vertexData interleaved [pos3, normal3, uv2] per vertex
28
+ * @param indices triangle list
29
+ * @param edgeScales per-vertex PMX edge scale, or omitted for 1
30
+ */
31
+ export function buildOutlineVertices(
32
+ vertexData: ArrayLike<number>,
33
+ stride: number,
34
+ indices: ArrayLike<number>,
35
+ edgeScales?: ArrayLike<number>,
36
+ ): Float32Array<ArrayBuffer> {
37
+ const n = Math.floor(vertexData.length / stride)
38
+ const out = new Float32Array(n * 4)
39
+
40
+ // Corner angles each vertex owns. A vertex no triangle uses keeps a token
41
+ // weight, so it still has a direction of its own.
42
+ const weight = new Float32Array(n).fill(1e-6)
43
+ for (let t = 0; t + 2 < indices.length; t += 3) {
44
+ const a = indices[t], b = indices[t + 1], c = indices[t + 2]
45
+ if (a >= n || b >= n || c >= n) continue
46
+ weight[a] += cornerAngle(vertexData, stride, a, b, c)
47
+ weight[b] += cornerAngle(vertexData, stride, b, c, a)
48
+ weight[c] += cornerAngle(vertexData, stride, c, a, b)
49
+ }
50
+
51
+ // Weld by quantized position.
52
+ const inv = 1 / OUTLINE_WELD_EPSILON
53
+ const group = new Int32Array(n)
54
+ const ids = new Map<string, number>()
55
+ let groups = 0
56
+ for (let i = 0; i < n; i++) {
57
+ const o = i * stride
58
+ const key = `${Math.round(vertexData[o] * inv)},${Math.round(vertexData[o + 1] * inv)},${Math.round(vertexData[o + 2] * inv)}`
59
+ let g = ids.get(key)
60
+ if (g === undefined) {
61
+ g = groups++
62
+ ids.set(key, g)
63
+ }
64
+ group[i] = g
65
+ }
66
+
67
+ const sum = new Float32Array(groups * 3)
68
+ for (let i = 0; i < n; i++) {
69
+ const o = i * stride
70
+ const w = weight[i]
71
+ const g = group[i] * 3
72
+ sum[g] += vertexData[o + 3] * w
73
+ sum[g + 1] += vertexData[o + 4] * w
74
+ sum[g + 2] += vertexData[o + 5] * w
75
+ }
76
+
77
+ for (let i = 0; i < n; i++) {
78
+ const o = i * stride
79
+ const nx = vertexData[o + 3], ny = vertexData[o + 4], nz = vertexData[o + 5]
80
+ const g = group[i] * 3
81
+ let x = sum[g], y = sum[g + 1], z = sum[g + 2]
82
+ const len = Math.hypot(x, y, z)
83
+ // Two sheets back to back at one position (a double-sided skirt, a shell
84
+ // and its lining) cancel to nothing, or to a direction pointing INTO one of
85
+ // them. Either way the welded answer is wrong for this vertex, and its own
86
+ // normal is the honest one.
87
+ if (len < 1e-6 || x * nx + y * ny + z * nz <= 0) {
88
+ x = nx
89
+ y = ny
90
+ z = nz
91
+ }
92
+ const l = Math.hypot(x, y, z) || 1
93
+ out[i * 4] = x / l
94
+ out[i * 4 + 1] = y / l
95
+ out[i * 4 + 2] = z / l
96
+ out[i * 4 + 3] = edgeScales ? edgeScales[i] : 1
97
+ }
98
+ return out
99
+ }
100
+
101
+ /** The angle at vertex `a` of triangle (a, b, c), in radians. */
102
+ function cornerAngle(v: ArrayLike<number>, stride: number, a: number, b: number, c: number): number {
103
+ const oa = a * stride, ob = b * stride, oc = c * stride
104
+ const ux = v[ob] - v[oa], uy = v[ob + 1] - v[oa + 1], uz = v[ob + 2] - v[oa + 2]
105
+ const wx = v[oc] - v[oa], wy = v[oc + 1] - v[oa + 1], wz = v[oc + 2] - v[oa + 2]
106
+ const lu = Math.hypot(ux, uy, uz), lw = Math.hypot(wx, wy, wz)
107
+ if (lu < 1e-12 || lw < 1e-12) return 0
108
+ const cos = (ux * wx + uy * wy + uz * wz) / (lu * lw)
109
+ return Math.acos(Math.max(-1, Math.min(1, cos)))
110
+ }
package/src/pmx-loader.ts CHANGED
@@ -36,6 +36,9 @@ export class PmxLoader {
36
36
  private inverseBindMatrices: Float32Array | null = null
37
37
  private joints0: Uint16Array | null = null
38
38
  private weights0: Uint8Array | null = null
39
+ /** PMX per-vertex edge scale — the outline's width multiplier, as the
40
+ * game's outline takes vertex colour alpha. */
41
+ private edgeScales: Float32Array | null = null
39
42
  private morphs: Morph[] = []
40
43
  private vertexCount: number = 0
41
44
  private rigidbodies: Rigidbody[] = []
@@ -150,6 +153,7 @@ export class PmxLoader {
150
153
  // Prepare skinning arrays (4 influences per vertex)
151
154
  const joints = new Uint16Array(count * 4)
152
155
  const weights = new Uint8Array(count * 4) // UNORM8, will be normalized to 255
156
+ const edgeScales = new Float32Array(count)
153
157
 
154
158
  for (let i = 0; i < count; i++) {
155
159
  const px = this.getFloat32()
@@ -238,11 +242,12 @@ export class PmxLoader {
238
242
  } else {
239
243
  throw new Error(`Invalid bone weight type: ${type}`)
240
244
  }
241
- this.offset += 4 // edge scale
245
+ edgeScales[i] = this.getFloat32()
242
246
  }
243
247
 
244
248
  this.joints0 = joints
245
249
  this.weights0 = weights
250
+ this.edgeScales = edgeScales
246
251
  return { positions, normals, uvs }
247
252
  }
248
253
 
@@ -1136,7 +1141,8 @@ export class PmxLoader {
1136
1141
  morphing,
1137
1142
  this.rigidbodies,
1138
1143
  this.joints,
1139
- this.warnings
1144
+ this.warnings,
1145
+ this.edgeScales
1140
1146
  )
1141
1147
  }
1142
1148
 
@@ -195,8 +195,10 @@ fn _rzCastShadow(wp: vec3f) -> f32 {
195
195
  // layers, as the game lights them;
196
196
  // ambient.x 1 while it has an ambient of its own (Engine.setModelAmbient),
197
197
  // 0 for the world's;
198
- // sh that ambient's irradiance, folded as the world's (ibl.ts).
199
- struct ObjectLight { layers: vec4u, ambient: vec4f, sh: array<vec4f, 9> }
198
+ // sh that ambient's irradiance, folded as the world's (ibl.ts);
199
+ // fill.rgb its fill (Engine.setModelFill), added after its graph times its
200
+ // surface colour — zero for a model that has none.
201
+ struct ObjectLight { layers: vec4u, ambient: vec4f, sh: array<vec4f, 9>, fill: vec4f }
200
202
  @group(1) @binding(1) var<uniform> objectLight: ObjectLight;
201
203
 
202
204
  /** The light arriving at a surface of this object facing n: its own ambient
@@ -1,7 +1,7 @@
1
- // EEVEE 3.6 bloom pyramid: blit (Karis prefilter) → 13-tap downsamples → 9-tap tent upsamples.
2
- // Mirrors source/blender/draw/engines/eevee/shaders/effect_bloom_frag.glsl. Firefly suppression
3
- // lives in the blit (Karis luminance-weighted 4-tap average). A single-pass Gaussian cannot
4
- // reproduce this — hot pixels dominate and produce the sparkle halo.
1
+ // Bloom — Aether Gazer's own chain (AGSimPostFX.Bloom, URP-style): a soft-knee
2
+ // prefilter to half size, a separable Gaussian down to a few pixels, then back
3
+ // up level by level, each blend leaning toward the coarser level by `scatter`.
4
+ // The composite adds the result × tint × intensity before the view transform.
5
5
 
6
6
  const FULLSCREEN_VS = /* wgsl */ `
7
7
  @vertex fn vs(@builtin(vertex_index) vi: u32) -> @builtin(position) vec4f {
@@ -11,114 +11,86 @@ const FULLSCREEN_VS = /* wgsl */ `
11
11
  }
12
12
  `
13
13
 
14
- // Full-res HDR → half-res. Karis 4-tap firefly average + EEVEE quadratic knee threshold + clamp.
15
- export const BLOOM_BLIT_SHADER_WGSL = `${FULLSCREEN_VS}
14
+ // Full-res HDR → half-res. The game takes ONE bilinear sample at the half-res
15
+ // pixel's centre, which is the 2×2 box of the block under it; here the four
16
+ // texels are loaded so each can be un-premultiplied (the scene target is
17
+ // premultiplied; its alpha lives in the aux target's .g) and gated by the bloom
18
+ // mask (.r) first.
19
+ export const BLOOM_PREFILTER_SHADER_WGSL = `${FULLSCREEN_VS}
16
20
  @group(0) @binding(0) var hdrTex: texture_2d<f32>;
17
- @group(0) @binding(1) var<uniform> prefilter: vec4<f32>; // threshold, knee, clamp, _unused
21
+ @group(0) @binding(1) var<uniform> params: vec4<f32>; // threshold, knee, clamp, scatter
18
22
  @group(0) @binding(2) var maskTex: texture_2d<f32>;
19
23
 
20
- fn luminance(c: vec3f) -> f32 {
21
- return dot(max(c, vec3f(0.0)), vec3f(0.2126, 0.7152, 0.0722));
22
- }
23
- fn fetch(c: vec2<i32>, clampV: f32) -> vec3f {
24
+ fn fetch(c: vec2<i32>) -> vec3f {
24
25
  let d = vec2<i32>(textureDimensions(hdrTex));
25
26
  let cc = clamp(c, vec2<i32>(0), d - vec2<i32>(1));
26
- let s = textureLoad(hdrTex, cc, 0);
27
- // hdrTex is rg11b10ufloat (no alpha channel). Alpha lives in maskTex.g, written
28
- // alongside the bloom mask in .r by the scene pass. Scene uses src-alpha blend
29
- // with clear alpha 0 → hdr rgb is premultiplied; divide by the aux alpha to
30
- // recover straight color before the Karis luminance weighting.
31
27
  let aux = textureLoad(maskTex, cc, 0);
32
- let mask = aux.r;
33
- let rgb = max(s.rgb / max(aux.g, 1e-6), vec3f(0.0));
34
- let masked = rgb * mask;
35
- // Blender clamps each tap BEFORE Karis average (eevee_bloom: color = min(clampIntensity, color)).
36
- return select(masked, min(masked, vec3f(clampV)), clampV > 0.0);
28
+ return max(textureLoad(hdrTex, cc, 0).rgb / max(aux.g, 1e-6), vec3f(0.0)) * aux.r;
37
29
  }
38
30
 
39
31
  @fragment fn fs(@builtin(position) p: vec4f) -> @location(0) vec4f {
40
- let dst = vec2<i32>(p.xy - vec2f(0.5));
41
- let base = dst * 2;
42
- let clampV = prefilter.z;
43
- let a = fetch(base + vec2<i32>(0, 0), clampV);
44
- let b = fetch(base + vec2<i32>(1, 0), clampV);
45
- let c = fetch(base + vec2<i32>(0, 1), clampV);
46
- let d = fetch(base + vec2<i32>(1, 1), clampV);
47
- // Karis partial average: weight each tap by 1/(1+luma) — suppresses fireflies.
48
- let wa = 1.0 / (1.0 + luminance(a));
49
- let wb = 1.0 / (1.0 + luminance(b));
50
- let wc = 1.0 / (1.0 + luminance(c));
51
- let wd = 1.0 / (1.0 + luminance(d));
52
- let avg = (a * wa + b * wb + c * wc + d * wd) / max(wa + wb + wc + wd, 1e-6);
53
- // EEVEE quadratic threshold (brightness = max-channel, then soft-knee curve).
54
- let bright = max(avg.r, max(avg.g, avg.b));
55
- let soft = clamp(bright - prefilter.x + prefilter.y, 0.0, 2.0 * prefilter.y);
56
- let q = (soft * soft) / (4.0 * max(prefilter.y, 1e-4) + 1e-6);
57
- let contrib = max(q, bright - prefilter.x) / max(bright, 1e-4);
58
- return vec4f(max(avg * contrib, vec3f(0.0)), 1.0);
32
+ let base = vec2<i32>(p.xy - vec2f(0.5)) * 2;
33
+ let avg = (fetch(base) + fetch(base + vec2<i32>(1, 0)) + fetch(base + vec2<i32>(0, 1)) + fetch(base + vec2<i32>(1, 1))) * 0.25;
34
+ let c = min(avg, vec3f(params.z));
35
+ // URP's quadratic soft knee on the brightest channel.
36
+ let bright = max(c.r, max(c.g, c.b));
37
+ let knee = params.y;
38
+ let soft = clamp(bright - params.x + knee, 0.0, 2.0 * knee);
39
+ let curve = soft * soft / (4.0 * knee + 1e-4);
40
+ return vec4f(c * (max(curve, bright - params.x) / max(bright, 1e-4)), 1.0);
59
41
  }
60
42
  `
61
43
 
62
- // Jimenez/COD 13-tap dual-box — 5 weighted 2×2 averages, rejects nyquist ringing.
63
- export const BLOOM_DOWNSAMPLE_SHADER_WGSL = `${FULLSCREEN_VS}
44
+ // Horizontal 9-tap Gaussian (σ ≈ 2 at the destination's scale), its taps two
45
+ // SOURCE texels apart: the destination is half the source, so this both halves
46
+ // and blurs. Each tap lands on a texel boundary, so bilinear averages pairs.
47
+ export const BLOOM_BLUR_H_SHADER_WGSL = `${FULLSCREEN_VS}
64
48
  @group(0) @binding(0) var srcTex: texture_2d<f32>;
65
49
  @group(0) @binding(1) var srcSamp: sampler;
66
50
 
67
- fn samp(uv: vec2f, off: vec2f) -> vec3f {
68
- return textureSampleLevel(srcTex, srcSamp, uv + off, 0.0).rgb;
51
+ @fragment fn fs(@builtin(position) p: vec4f) -> @location(0) vec4f {
52
+ let srcDims = vec2f(textureDimensions(srcTex));
53
+ let dstDims = max(floor(srcDims * 0.5), vec2f(1.0));
54
+ let uv = p.xy / dstDims;
55
+ let dx = vec2f(2.0 / srcDims.x, 0.0);
56
+ var w = array<f32, 5>(0.22702703, 0.19459459, 0.12162162, 0.05405405, 0.01621622);
57
+ var o = textureSampleLevel(srcTex, srcSamp, uv, 0.0).rgb * w[0];
58
+ for (var i = 1; i < 5; i++) {
59
+ let off = dx * f32(i);
60
+ o += (textureSampleLevel(srcTex, srcSamp, uv - off, 0.0).rgb + textureSampleLevel(srcTex, srcSamp, uv + off, 0.0).rgb) * w[i];
61
+ }
62
+ return vec4f(o, 1.0);
69
63
  }
64
+ `
65
+
66
+ // Vertical pass at the same size: the same 9-tap Gaussian as five bilinear taps.
67
+ export const BLOOM_BLUR_V_SHADER_WGSL = `${FULLSCREEN_VS}
68
+ @group(0) @binding(0) var srcTex: texture_2d<f32>;
69
+ @group(0) @binding(1) var srcSamp: sampler;
70
70
 
71
71
  @fragment fn fs(@builtin(position) p: vec4f) -> @location(0) vec4f {
72
- let srcDims = vec2f(textureDimensions(srcTex));
73
- let t = 1.0 / srcDims;
74
- // fragCoord.xy reports pixel centers (e.g. 0.5,0.5 for first pixel) — divide by dst dims directly.
75
- let dstDims = srcDims * 0.5;
76
- let uv = p.xy / max(dstDims, vec2f(1.0));
77
- let A = samp(uv, t * vec2f(-2.0, -2.0));
78
- let B = samp(uv, t * vec2f( 0.0, -2.0));
79
- let C = samp(uv, t * vec2f( 2.0, -2.0));
80
- let D = samp(uv, t * vec2f(-1.0, -1.0));
81
- let E = samp(uv, t * vec2f( 1.0, -1.0));
82
- let F = samp(uv, t * vec2f(-2.0, 0.0));
83
- let G = samp(uv, t * vec2f( 0.0, 0.0));
84
- let H = samp(uv, t * vec2f( 2.0, 0.0));
85
- let I = samp(uv, t * vec2f(-1.0, 1.0));
86
- let J = samp(uv, t * vec2f( 1.0, 1.0));
87
- let K = samp(uv, t * vec2f(-2.0, 2.0));
88
- let L = samp(uv, t * vec2f( 0.0, 2.0));
89
- let M = samp(uv, t * vec2f( 2.0, 2.0));
90
- var o = (D + E + I + J) * (0.5 / 4.0);
91
- o = o + (A + B + G + F) * (0.125 / 4.0);
92
- o = o + (B + C + H + G) * (0.125 / 4.0);
93
- o = o + (F + G + L + K) * (0.125 / 4.0);
94
- o = o + (G + H + M + L) * (0.125 / 4.0);
72
+ let dims = vec2f(textureDimensions(srcTex));
73
+ let uv = p.xy / dims;
74
+ let near = vec2f(0.0, 1.3846154 / dims.y);
75
+ let far = vec2f(0.0, 3.2307692 / dims.y);
76
+ var o = textureSampleLevel(srcTex, srcSamp, uv, 0.0).rgb * 0.22702703;
77
+ o += (textureSampleLevel(srcTex, srcSamp, uv - near, 0.0).rgb + textureSampleLevel(srcTex, srcSamp, uv + near, 0.0).rgb) * 0.31621623;
78
+ o += (textureSampleLevel(srcTex, srcSamp, uv - far, 0.0).rgb + textureSampleLevel(srcTex, srcSamp, uv + far, 0.0).rgb) * 0.07027027;
95
79
  return vec4f(o, 1.0);
96
80
  }
97
81
  `
98
82
 
99
- // 9-tap tent, progressively added to matching downsample mip. Blender radius = sample scale.
83
+ // One level up: this level's blur leaned toward the coarser one by scatter.
100
84
  export const BLOOM_UPSAMPLE_SHADER_WGSL = `${FULLSCREEN_VS}
101
- @group(0) @binding(0) var srcTex: texture_2d<f32>; // coarser accumulator
102
- @group(0) @binding(1) var baseTex: texture_2d<f32>; // matching downsample mip
85
+ @group(0) @binding(0) var highTex: texture_2d<f32>; // down[i]
86
+ @group(0) @binding(1) var lowTex: texture_2d<f32>; // coarser: down[i+1] at the top, up[i+1] after
103
87
  @group(0) @binding(2) var srcSamp: sampler;
104
- @group(0) @binding(3) var<uniform> upU: vec4<f32>; // sampleScale, _, _, _
88
+ @group(0) @binding(3) var<uniform> params: vec4<f32>; // threshold, knee, clamp, scatter
105
89
 
106
90
  @fragment fn fs(@builtin(position) p: vec4f) -> @location(0) vec4f {
107
- let srcDims = vec2f(textureDimensions(srcTex));
108
- let baseDims = vec2f(textureDimensions(baseTex));
109
- let uv = p.xy / max(baseDims, vec2f(1.0));
110
- let t = upU.x / srcDims;
111
- var o = textureSampleLevel(srcTex, srcSamp, uv + t * vec2f(-1.0, -1.0), 0.0).rgb * 1.0;
112
- o = o + textureSampleLevel(srcTex, srcSamp, uv + t * vec2f( 0.0, -1.0), 0.0).rgb * 2.0;
113
- o = o + textureSampleLevel(srcTex, srcSamp, uv + t * vec2f( 1.0, -1.0), 0.0).rgb * 1.0;
114
- o = o + textureSampleLevel(srcTex, srcSamp, uv + t * vec2f(-1.0, 0.0), 0.0).rgb * 2.0;
115
- o = o + textureSampleLevel(srcTex, srcSamp, uv + t * vec2f( 0.0, 0.0), 0.0).rgb * 4.0;
116
- o = o + textureSampleLevel(srcTex, srcSamp, uv + t * vec2f( 1.0, 0.0), 0.0).rgb * 2.0;
117
- o = o + textureSampleLevel(srcTex, srcSamp, uv + t * vec2f(-1.0, 1.0), 0.0).rgb * 1.0;
118
- o = o + textureSampleLevel(srcTex, srcSamp, uv + t * vec2f( 0.0, 1.0), 0.0).rgb * 2.0;
119
- o = o + textureSampleLevel(srcTex, srcSamp, uv + t * vec2f( 1.0, 1.0), 0.0).rgb * 1.0;
120
- o = o * (1.0 / 16.0);
121
- let base = textureSampleLevel(baseTex, srcSamp, uv, 0.0).rgb;
122
- return vec4f(o + base, 1.0);
91
+ let uv = p.xy / vec2f(textureDimensions(highTex));
92
+ let high = textureSampleLevel(highTex, srcSamp, uv, 0.0).rgb;
93
+ let low = textureSampleLevel(lowTex, srcSamp, uv, 0.0).rgb;
94
+ return vec4f(mix(high, low, params.w), 1.0);
123
95
  }
124
96
  `