@forgeax/engine-shader 0.1.2

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 (162) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +99 -0
  3. package/dist/.tsbuildinfo +1 -0
  4. package/dist/ShaderRegistry.d.ts +208 -0
  5. package/dist/ShaderRegistry.d.ts.map +1 -0
  6. package/dist/__tests__/atmosphere-background.test.d.ts +2 -0
  7. package/dist/__tests__/atmosphere-background.test.d.ts.map +1 -0
  8. package/dist/__tests__/atmosphere-cubemap-orientation.test.d.ts +2 -0
  9. package/dist/__tests__/atmosphere-cubemap-orientation.test.d.ts.map +1 -0
  10. package/dist/__tests__/atmosphere-preetham-numeric.test.d.ts +2 -0
  11. package/dist/__tests__/atmosphere-preetham-numeric.test.d.ts.map +1 -0
  12. package/dist/__tests__/atmosphere-preetham-red.test.d.ts +2 -0
  13. package/dist/__tests__/atmosphere-preetham-red.test.d.ts.map +1 -0
  14. package/dist/__tests__/atmosphere-preetham.test.d.ts +2 -0
  15. package/dist/__tests__/atmosphere-preetham.test.d.ts.map +1 -0
  16. package/dist/__tests__/atmosphere-three-r184-reference.test.d.ts +2 -0
  17. package/dist/__tests__/atmosphere-three-r184-reference.test.d.ts.map +1 -0
  18. package/dist/__tests__/bloom-fxaa-tonemap.unit.test.d.ts +2 -0
  19. package/dist/__tests__/bloom-fxaa-tonemap.unit.test.d.ts.map +1 -0
  20. package/dist/__tests__/builtin-texture-sampling-contract.test.d.ts +2 -0
  21. package/dist/__tests__/builtin-texture-sampling-contract.test.d.ts.map +1 -0
  22. package/dist/__tests__/default-standard-pbr-alpha.unit.test.d.ts +2 -0
  23. package/dist/__tests__/default-standard-pbr-alpha.unit.test.d.ts.map +1 -0
  24. package/dist/__tests__/deferred-lighting-ssao.test.d.ts +2 -0
  25. package/dist/__tests__/deferred-lighting-ssao.test.d.ts.map +1 -0
  26. package/dist/__tests__/fog-oracle.unit.test.d.ts +2 -0
  27. package/dist/__tests__/fog-oracle.unit.test.d.ts.map +1 -0
  28. package/dist/__tests__/fog-orthographic-ray.unit.test.d.ts +2 -0
  29. package/dist/__tests__/fog-orthographic-ray.unit.test.d.ts.map +1 -0
  30. package/dist/__tests__/fog-producer-matrix.integration.test.d.ts +2 -0
  31. package/dist/__tests__/fog-producer-matrix.integration.test.d.ts.map +1 -0
  32. package/dist/__tests__/hdrp-ssao-compile.test.d.ts +2 -0
  33. package/dist/__tests__/hdrp-ssao-compile.test.d.ts.map +1 -0
  34. package/dist/__tests__/ibl-irradiance.unit.test.d.ts +2 -0
  35. package/dist/__tests__/ibl-irradiance.unit.test.d.ts.map +1 -0
  36. package/dist/__tests__/ibl-sampling.unit.test.d.ts +2 -0
  37. package/dist/__tests__/ibl-sampling.unit.test.d.ts.map +1 -0
  38. package/dist/__tests__/lighting-punctual.unit.test.d.ts +2 -0
  39. package/dist/__tests__/lighting-punctual.unit.test.d.ts.map +1 -0
  40. package/dist/__tests__/material-artifact-dedup.unit.test.d.ts +2 -0
  41. package/dist/__tests__/material-artifact-dedup.unit.test.d.ts.map +1 -0
  42. package/dist/__tests__/material-builtins.unit.test.d.ts +2 -0
  43. package/dist/__tests__/material-builtins.unit.test.d.ts.map +1 -0
  44. package/dist/__tests__/material-contract.unit.test.d.ts +2 -0
  45. package/dist/__tests__/material-contract.unit.test.d.ts.map +1 -0
  46. package/dist/__tests__/material-derived-builtins.integration.test.d.ts +2 -0
  47. package/dist/__tests__/material-derived-builtins.integration.test.d.ts.map +1 -0
  48. package/dist/__tests__/public-surface.unit.test.d.ts +2 -0
  49. package/dist/__tests__/public-surface.unit.test.d.ts.map +1 -0
  50. package/dist/__tests__/scene-temporal.unit.test.d.ts +2 -0
  51. package/dist/__tests__/scene-temporal.unit.test.d.ts.map +1 -0
  52. package/dist/__tests__/shader-registry-transaction.unit.test.d.ts +2 -0
  53. package/dist/__tests__/shader-registry-transaction.unit.test.d.ts.map +1 -0
  54. package/dist/__tests__/shader.unit.test.d.ts +2 -0
  55. package/dist/__tests__/shader.unit.test.d.ts.map +1 -0
  56. package/dist/__tests__/sprite-lit-shader.test.d.ts +2 -0
  57. package/dist/__tests__/sprite-lit-shader.test.d.ts.map +1 -0
  58. package/dist/__tests__/sprite-variants.unit.test.d.ts +2 -0
  59. package/dist/__tests__/sprite-variants.unit.test.d.ts.map +1 -0
  60. package/dist/__tests__/taa-resolve.unit.test.d.ts +2 -0
  61. package/dist/__tests__/taa-resolve.unit.test.d.ts.map +1 -0
  62. package/dist/__tests__/transparent-pbr.unit.test.d.ts +2 -0
  63. package/dist/__tests__/transparent-pbr.unit.test.d.ts.map +1 -0
  64. package/dist/__tests__/vertex-color-variant.unit.test.d.ts +2 -0
  65. package/dist/__tests__/vertex-color-variant.unit.test.d.ts.map +1 -0
  66. package/dist/errors.d.ts +96 -0
  67. package/dist/errors.d.ts.map +1 -0
  68. package/dist/index.d.ts +44 -0
  69. package/dist/index.d.ts.map +1 -0
  70. package/dist/index.mjs +560 -0
  71. package/dist/index.mjs.map +1 -0
  72. package/dist/material/artifact-registry.d.ts +31 -0
  73. package/dist/material/artifact-registry.d.ts.map +1 -0
  74. package/dist/material/artifact-types.d.ts +13 -0
  75. package/dist/material/artifact-types.d.ts.map +1 -0
  76. package/dist/material-schemas.d.ts +8 -0
  77. package/dist/material-schemas.d.ts.map +1 -0
  78. package/dist/register-default-sprite-lit.d.ts +40 -0
  79. package/dist/register-default-sprite-lit.d.ts.map +1 -0
  80. package/dist/register-default-standard-pbr-skin.d.ts +34 -0
  81. package/dist/register-default-standard-pbr-skin.d.ts.map +1 -0
  82. package/dist/tonemap.d.ts +13 -0
  83. package/dist/tonemap.d.ts.map +1 -0
  84. package/dist/types.d.ts +47 -0
  85. package/dist/types.d.ts.map +1 -0
  86. package/package.json +58 -0
  87. package/src/ShaderRegistry.ts +519 -0
  88. package/src/__tests__/atmosphere-background.test.ts +33 -0
  89. package/src/__tests__/atmosphere-cubemap-orientation.test.ts +68 -0
  90. package/src/__tests__/atmosphere-preetham-numeric.test.ts +122 -0
  91. package/src/__tests__/atmosphere-preetham-red.test.ts +52 -0
  92. package/src/__tests__/atmosphere-preetham.test.ts +132 -0
  93. package/src/__tests__/atmosphere-three-r184-reference.test.ts +122 -0
  94. package/src/__tests__/bloom-fxaa-tonemap.unit.test.ts +31 -0
  95. package/src/__tests__/builtin-texture-sampling-contract.test.ts +56 -0
  96. package/src/__tests__/default-standard-pbr-alpha.unit.test.ts +19 -0
  97. package/src/__tests__/deferred-lighting-ssao.test.ts +114 -0
  98. package/src/__tests__/fog-oracle.unit.test.ts +132 -0
  99. package/src/__tests__/fog-orthographic-ray.unit.test.ts +62 -0
  100. package/src/__tests__/fog-producer-matrix.integration.test.ts +77 -0
  101. package/src/__tests__/hdrp-ssao-compile.test.ts +185 -0
  102. package/src/__tests__/ibl-irradiance.unit.test.ts +43 -0
  103. package/src/__tests__/ibl-sampling.unit.test.ts +40 -0
  104. package/src/__tests__/lighting-punctual.unit.test.ts +66 -0
  105. package/src/__tests__/manifest.fixture.json +22 -0
  106. package/src/__tests__/material-artifact-dedup.unit.test.ts +21 -0
  107. package/src/__tests__/material-builtins.unit.test.ts +111 -0
  108. package/src/__tests__/material-contract.unit.test.ts +79 -0
  109. package/src/__tests__/material-derived-builtins.integration.test.ts +30 -0
  110. package/src/__tests__/public-surface.unit.test.ts +9 -0
  111. package/src/__tests__/scene-temporal.unit.test.ts +32 -0
  112. package/src/__tests__/shader-registry-transaction.unit.test.ts +116 -0
  113. package/src/__tests__/shader.unit.test.ts +1909 -0
  114. package/src/__tests__/sprite-lit-shader.test.ts +240 -0
  115. package/src/__tests__/sprite-variants.unit.test.ts +209 -0
  116. package/src/__tests__/taa-resolve.unit.test.ts +32 -0
  117. package/src/__tests__/transparent-pbr.unit.test.ts +21 -0
  118. package/src/__tests__/vertex-color-variant.unit.test.ts +52 -0
  119. package/src/atmosphere-background.wgsl +80 -0
  120. package/src/atmosphere-cubemap.wgsl +72 -0
  121. package/src/atmosphere-ibl.wgsl +127 -0
  122. package/src/atmosphere-preetham.wgsl +214 -0
  123. package/src/bloom-blur.wgsl +89 -0
  124. package/src/bloom-bright.wgsl +50 -0
  125. package/src/bloom-composite.wgsl +54 -0
  126. package/src/brdf.wgsl +25 -0
  127. package/src/common.wgsl +523 -0
  128. package/src/default-standard-pbr-skin.wgsl +538 -0
  129. package/src/default-standard-pbr.wgsl +794 -0
  130. package/src/errors.ts +169 -0
  131. package/src/fog.wgsl +27 -0
  132. package/src/fxaa.wgsl +259 -0
  133. package/src/hdrp-cluster-forward.wgsl +186 -0
  134. package/src/hdrp-deferred-lighting.wgsl +69 -0
  135. package/src/hdrp-ssao.wgsl +174 -0
  136. package/src/ibl-brdf-lut.wgsl +92 -0
  137. package/src/ibl-equirect-to-cube.wgsl +56 -0
  138. package/src/ibl-irradiance.wgsl +89 -0
  139. package/src/ibl-prefilter.wgsl +101 -0
  140. package/src/ibl-sampling.wgsl +58 -0
  141. package/src/ibl-shared.wgsl +118 -0
  142. package/src/index.ts +166 -0
  143. package/src/lighting-directional.wgsl +471 -0
  144. package/src/lighting-punctual.wgsl +274 -0
  145. package/src/material/artifact-registry.ts +78 -0
  146. package/src/material/artifact-types.ts +26 -0
  147. package/src/material-schemas.ts +48 -0
  148. package/src/msdf-text.wgsl +208 -0
  149. package/src/pbr-temporal.wgsl +73 -0
  150. package/src/register-default-sprite-lit.ts +87 -0
  151. package/src/register-default-standard-pbr-skin.ts +74 -0
  152. package/src/shadow-pcf.wgsl +120 -0
  153. package/src/shadow_caster.wgsl +54 -0
  154. package/src/skybox.wgsl +75 -0
  155. package/src/sprite-lit.wgsl +307 -0
  156. package/src/sprite.wgsl +365 -0
  157. package/src/taa-resolve.wgsl +129 -0
  158. package/src/tbn.wgsl +54 -0
  159. package/src/tonemap.ts +13 -0
  160. package/src/tonemap.wgsl +177 -0
  161. package/src/types.ts +59 -0
  162. package/src/unlit.wgsl +186 -0
@@ -0,0 +1,274 @@
1
+ #define_import_path forgeax_pbr::lighting_punctual
2
+
3
+ // @forgeax/engine-shader - lighting-punctual.wgsl
4
+ // (feat-20260523-shader-template-instance-split M5 / T02).
5
+ //
6
+ // Point + spot light evaluators extracted from pbr.wgsl
7
+ // (feat-20260519-light-casters-point-spot-pbr M4 / w22 byte-equivalent
8
+ // extraction). Both light types share a punctual BRDF body (GGX specular +
9
+ // Lambertian diffuse + Three r184 squared finite-range attenuation); the
10
+ // only difference is that SpotLight multiplies a cone-falloff factor
11
+ // `smoothstep(cosOuter, cosInner, dot(l, -lightDir))` on top.
12
+ //
13
+ // charter P4 consistent abstraction: one body, two thin wrappers that each
14
+ // carry exactly the parameters their light type needs. evalPoint avoids the
15
+ // "evalPunctual(cosInner=1, cosOuter=-1, ...)" magic-value collapse pattern
16
+ // since `smoothstep(-1, 1, x)` is the Hermite cubic 0..1 (not a constant 1).
17
+ //
18
+ // Range attenuation (Three r184 squared finite-range authority):
19
+ // atten = clamp(1 - (d^2 * invR^2)^2, 0, 1)^2 / max(d^2, 1e-4)
20
+ // `max(d^2, 1e-4)` math safety net keeps the divisor finite when the
21
+ // fragment is at the light position (zero-distance NaN guard, layer 2 of
22
+ // the two-layer fail-fast strategy alongside the host-side bounds gate).
23
+ // `invRangeSquared = 0` collapses the quartic falloff to a pure 1/d^2 law.
24
+ //
25
+ // Pure-function module aside from the brdf #import; takes all light + surface
26
+ // parameters as args so the helper does not declare its own bindings (host
27
+ // material shader owns the @group(0) light buffer namespace).
28
+ //
29
+ // Exports:
30
+ // - evalPoint(lightPos, colorTimesIntensity, invRangeSquared, ...) -> vec3<f32>
31
+ // - evalSpot(lightPos, lightDir, colorTimesIntensity, cosInner, cosOuter,
32
+ // invRangeSquared, ...) -> vec3<f32>
33
+
34
+ #import forgeax_pbr::brdf::{f_schlick, v_smith, d_ggx}
35
+ // feat-20260625-spot-light-shadow-mapping M3 / w15 (plan-strategy D-3 + D-5):
36
+ // spot shadow sampling reuses the shared 2D 9-tap PCF core (sample_shadow_2d)
37
+ // and the always-on `spotShadowMap` (binding 8) + `shadowSampler` (binding 4).
38
+ // `shadowSampler` is imported UNCONDITIONALLY here (spot is always-on, D-5):
39
+ // the point-shadow #ifdef block below must NOT re-import it (double import).
40
+ #import forgeax_pbr::shadow_pcf::{sample_shadow_2d}
41
+ #import forgeax_view::common::{spotShadowMap, shadowSampler}
42
+ #ifdef POINT_SHADOW_AVAILABLE
43
+ #import forgeax_pbr::shadow_pcf::{sample_shadow_cube_hw2x2}
44
+ // Pull in the @group(0) @binding(5) shadowAtlas declaration from common.wgsl so
45
+ // the free-identifier references in `evalPointShadowed` resolve through
46
+ // naga_oil's import scope. (`shadowSampler` is already imported above for the
47
+ // always-on spot path.)
48
+ #import forgeax_view::common::{shadowAtlas}
49
+ #endif
50
+
51
+ // Shared punctual BRDF body returning (diffuse + specular) *
52
+ // colorTimesIntensity * nDotL * attenuation. Cone factor is applied by the
53
+ // caller (evalSpot only).
54
+ fn evalPunctualBody(
55
+ lightPos : vec3<f32>,
56
+ colorTimesIntensity : vec3<f32>,
57
+ invRangeSquared : f32,
58
+ worldPos : vec3<f32>,
59
+ normal : vec3<f32>,
60
+ viewDir : vec3<f32>,
61
+ baseColor : vec3<f32>,
62
+ metallic : f32,
63
+ alphaSq : f32,
64
+ F0 : vec3<f32>,
65
+ ) -> vec3<f32> {
66
+ let toLight = lightPos - worldPos;
67
+ let dSquared = max(dot(toLight, toLight), 1e-4);
68
+ let l = toLight / sqrt(dSquared);
69
+ let h = normalize(viewDir + l);
70
+ let nDotL = max(dot(normal, l), 0.0);
71
+ let nDotV = max(dot(normal, viewDir), 1e-5);
72
+ let nDotH = max(dot(normal, h), 0.0);
73
+ let vDotH = max(dot(viewDir, h), 0.0);
74
+ let f = f_schlick(vDotH, F0);
75
+ let specular = d_ggx(nDotH, alphaSq) * v_smith(nDotV, nDotL, alphaSq) * f;
76
+ let kd = (vec3<f32>(1.0) - f) * (1.0 - metallic);
77
+ let diffuse = kd * baseColor / 3.14159265;
78
+ let factor = max(min(1.0 - (dSquared * invRangeSquared) * (dSquared * invRangeSquared), 1.0), 0.0);
79
+ let attenuation = factor * factor / dSquared;
80
+ return (diffuse + specular) * colorTimesIntensity * nDotL * attenuation;
81
+ }
82
+
83
+ // Omnidirectional point light: no cone factor.
84
+ fn evalPoint(
85
+ lightPos : vec3<f32>,
86
+ colorTimesIntensity : vec3<f32>,
87
+ invRangeSquared : f32,
88
+ worldPos : vec3<f32>,
89
+ normal : vec3<f32>,
90
+ viewDir : vec3<f32>,
91
+ baseColor : vec3<f32>,
92
+ metallic : f32,
93
+ alphaSq : f32,
94
+ F0 : vec3<f32>,
95
+ ) -> vec3<f32> {
96
+ return evalPunctualBody(
97
+ lightPos, colorTimesIntensity, invRangeSquared,
98
+ worldPos, normal, viewDir, baseColor, metallic, alphaSq, F0,
99
+ );
100
+ }
101
+
102
+ #ifdef POINT_SHADOW_AVAILABLE
103
+ // Shadow-modulated omnidirectional point light: same BRDF body * shadow factor.
104
+ //
105
+ // feat-20260612-point-light-shadows-urp-hdrp M3 / T-M3-3 + M4 / T-M4-3
106
+ // (plan-strategy §D-1 + §D-8). Only emitted when POINT_SHADOW_AVAILABLE is
107
+ // true (forward path with the cube_array atlas at @group(0) binding 5).
108
+ // The shadow factor is reconstructed from `lightLocal` via the largest-axis
109
+ // projection (research L0.5 Bevy pattern); the caller passes `near` / `far`
110
+ // directly so both pipelines route the same constants without owning the
111
+ // upstream binding (URP reads them from `shadowParams[layer]` at @group(0)
112
+ // binding 6; HDRP unpacks them off `LightSlot.kind_and_pad.zw` per
113
+ // plan-strategy §D-8).
114
+ //
115
+ // Caller responsibility: gate this on `shadowAtlasLayer >= 0` so the
116
+ // no-shadow lights stay on the unshadowed `evalPoint` path; passing a
117
+ // negative layer here is undefined (the cube_array view rejects it).
118
+ fn evalPointShadowed(
119
+ lightPos : vec3<f32>,
120
+ colorTimesIntensity : vec3<f32>,
121
+ invRangeSquared : f32,
122
+ worldPos : vec3<f32>,
123
+ normal : vec3<f32>,
124
+ viewDir : vec3<f32>,
125
+ baseColor : vec3<f32>,
126
+ metallic : f32,
127
+ alphaSq : f32,
128
+ F0 : vec3<f32>,
129
+ shadowAtlasLayer : i32,
130
+ near : f32,
131
+ far : f32,
132
+ depthBias : f32,
133
+ normalBias : f32,
134
+ ) -> vec3<f32> {
135
+ let lit = evalPunctualBody(
136
+ lightPos, colorTimesIntensity, invRangeSquared,
137
+ worldPos, normal, viewDir, baseColor, metallic, alphaSq, F0,
138
+ );
139
+ // Fragment-to-light direction; cubemap sample uses the local-space
140
+ // direction (research L0.5: Bevy convention). For a right-handed world,
141
+ // the cubemap convention flips Z so the +Z face look direction matches.
142
+ let toLight = lightPos - worldPos;
143
+ // Cubemap sample direction is from-fragment-to-light (Bevy + LearnOpenGL),
144
+ // negated to fragment-from-light when reconstructing the depth ref.
145
+ let lightLocal = vec3<f32>(toLight.x, toLight.y, -toLight.z);
146
+ // Reconstruct [0,1] NDC depth from world-space distance: largest-axis
147
+ // projection (research L0.5). Match the per-face perspective near / far
148
+ // configured by buildPointShadowMatrices (PointLightShadow.nearPlane /
149
+ // farPlane on the host).
150
+ let absV = abs(vec3<f32>(toLight.x, toLight.y, toLight.z));
151
+ let largestAxis = max(absV.x, max(absV.y, absV.z));
152
+ // Perspective z-NDC reconstruction for the largest axis as the eye-space
153
+ // -z component (cube face look direction is the +axis the absolute value
154
+ // selected). z_ndc = far * (largest - near) / (largest * (far - near)).
155
+ let denom = max(largestAxis * (far - near), 1e-6);
156
+ let depthRef = clamp(far * (largestAxis - near) / denom, 0.0, 1.0);
157
+ let nDotL = max(dot(normal, normalize(toLight)), 0.0);
158
+ let shadowFactor = sample_shadow_cube_hw2x2(
159
+ shadowAtlas, shadowSampler, lightLocal, shadowAtlasLayer,
160
+ depthRef, depthBias, normalBias, nDotL,
161
+ );
162
+ return lit * shadowFactor;
163
+ }
164
+ #endif
165
+
166
+ // Cone-restricted spot light: BRDF body * smoothstep cone factor.
167
+ // `cosInner` / `cosOuter` are pre-computed cosines (host-side
168
+ // degree -> cosine conversion in extract-frame; plan-strategy D-S2).
169
+ fn evalSpot(
170
+ lightPos : vec3<f32>,
171
+ lightDir : vec3<f32>,
172
+ colorTimesIntensity : vec3<f32>,
173
+ cosInner : f32,
174
+ cosOuter : f32,
175
+ invRangeSquared : f32,
176
+ worldPos : vec3<f32>,
177
+ normal : vec3<f32>,
178
+ viewDir : vec3<f32>,
179
+ baseColor : vec3<f32>,
180
+ metallic : f32,
181
+ alphaSq : f32,
182
+ F0 : vec3<f32>,
183
+ ) -> vec3<f32> {
184
+ let body = evalPunctualBody(
185
+ lightPos, colorTimesIntensity, invRangeSquared,
186
+ worldPos, normal, viewDir, baseColor, metallic, alphaSq, F0,
187
+ );
188
+ let toLight = lightPos - worldPos;
189
+ let l = normalize(toLight);
190
+ let cone = smoothstep(cosOuter, cosInner, dot(l, -lightDir));
191
+ return body * cone;
192
+ }
193
+
194
+ // feat-20260625-spot-light-shadow-mapping M3 / w15 (plan-strategy D-3 + D-4 +
195
+ // D-5). Shadow-modulated spot light: the unshadowed `evalSpot` result times a
196
+ // PCF shadow factor sampled from the spot's perspective depth-atlas tile.
197
+ //
198
+ // Mirrors `evalPointShadowed`'s "shadowed wrapper + upstream gate" pattern
199
+ // (research Finding B3): the caller gates on `shadowAtlasTile >= 0` so
200
+ // no-shadow / clipped / direction-degenerate spots (tile = -1, plan D-4) stay
201
+ // on the unshadowed `evalSpot` path.
202
+ //
203
+ // Depth-ref reconstruction is the standard perspective `splane.z / splane.w`
204
+ // non-linear depth (plan-strategy D-4, godot-point-spot-shadows wiki S3.4):
205
+ // store-side and sample-side share the SAME perspective `lightViewProj`, so the
206
+ // projection's non-linearity cancels and no near/far reconstruction is needed
207
+ // (unlike the point cube path's largest-axis projection).
208
+ //
209
+ // Atlas tiling: the host packs up to 4 spot shadows into a 2x2 grid of one
210
+ // `spotShadowDepth` texture (urp-pipeline.ts). Tile N occupies quadrant
211
+ // (col = N % 2, row = N / 2); the [0,1] light-clip UV is scaled to a 0.5x0.5
212
+ // sub-rect and offset to the tile origin. PCF taps stay inside the tile by
213
+ // scaling the texel step to the half-resolution sub-rect.
214
+ //
215
+ // OOB / NaN gate (research Finding F1, mirrors lighting-directional.wgsl): a
216
+ // degenerate `lightViewProj` (near-zero spot direction) yields NaN UVs; the
217
+ // `>= 0 && <= 1` form is false for NaN, so the fragment returns fully lit
218
+ // (shadowFactor = 1.0) instead of a hard-black artifact.
219
+ fn evalSpotShadowed(
220
+ lightPos : vec3<f32>,
221
+ lightDir : vec3<f32>,
222
+ colorTimesIntensity : vec3<f32>,
223
+ cosInner : f32,
224
+ cosOuter : f32,
225
+ invRangeSquared : f32,
226
+ worldPos : vec3<f32>,
227
+ normal : vec3<f32>,
228
+ viewDir : vec3<f32>,
229
+ baseColor : vec3<f32>,
230
+ metallic : f32,
231
+ alphaSq : f32,
232
+ F0 : vec3<f32>,
233
+ lightViewProj : mat4x4<f32>,
234
+ shadowAtlasTile : i32,
235
+ depthBias : f32,
236
+ normalBias : f32,
237
+ ) -> vec3<f32> {
238
+ let body = evalSpot(
239
+ lightPos, lightDir, colorTimesIntensity, cosInner, cosOuter, invRangeSquared,
240
+ worldPos, normal, viewDir, baseColor, metallic, alphaSq, F0,
241
+ );
242
+
243
+ // Project the fragment into the spot's light clip space.
244
+ let splane = lightViewProj * vec4<f32>(worldPos, 1.0);
245
+ // Perspective divide; guard a zero/near-zero w (fragment behind the light or
246
+ // a degenerate matrix) so the OOB gate below catches it as fully lit.
247
+ let invW = select(1.0 / splane.w, 0.0, abs(splane.w) < 1e-6);
248
+ let ndcXY = splane.xy * invW;
249
+ let depthRef = splane.z * invW;
250
+ // Clip-space [-1,1] -> texture UV [0,1] with the standard Y flip.
251
+ let clipUv = vec2<f32>(ndcXY.x * 0.5 + 0.5, ndcXY.y * -0.5 + 0.5);
252
+
253
+ // OOB / NaN gate: outside the light frustum (or NaN from a degenerate matrix)
254
+ // returns fully lit. Mirrors the directional `>= 0 && <= 1` NaN-safe form.
255
+ if (!(clipUv.x >= 0.0 && clipUv.x <= 1.0 && clipUv.y >= 0.0 && clipUv.y <= 1.0 && depthRef <= 1.0)) {
256
+ return body;
257
+ }
258
+
259
+ // Map the [0,1] light-clip UV into the spot's 2x2 atlas tile sub-rect.
260
+ let col = f32(shadowAtlasTile % 2);
261
+ let row = f32(shadowAtlasTile / 2);
262
+ let tileOrigin = vec2<f32>(col, row) * 0.5;
263
+ let atlasUv = clipUv * 0.5 + tileOrigin;
264
+
265
+ // texel step within the half-resolution sub-rect (atlas is 2x tile size).
266
+ let atlasDims = vec2<f32>(textureDimensions(spotShadowMap, 0));
267
+ let texel = vec2<f32>(1.0, 1.0) / atlasDims;
268
+
269
+ let nDotL = max(dot(normal, normalize(lightPos - worldPos)), 0.0);
270
+ let shadowFactor = sample_shadow_2d(
271
+ spotShadowMap, shadowSampler, atlasUv, texel, depthRef, normalBias, depthBias, nDotL,
272
+ );
273
+ return body * shadowFactor;
274
+ }
@@ -0,0 +1,78 @@
1
+ import { err, ok, type Result } from '@forgeax/engine-types';
2
+
3
+ export interface MaterialRuntimeArtifact {
4
+ readonly key: string;
5
+ readonly bytes: Uint8Array;
6
+ readonly digest?: string;
7
+ readonly metadata?: Readonly<Record<string, unknown>>;
8
+ }
9
+
10
+ export interface MaterialArtifactInspection {
11
+ readonly key: string;
12
+ readonly digest?: string;
13
+ readonly byteLength: number;
14
+ readonly metadata?: Readonly<Record<string, unknown>>;
15
+ }
16
+
17
+ export interface MaterialArtifactConflictError {
18
+ readonly code: 'material-artifact-conflict';
19
+ readonly expected: string;
20
+ readonly hint: string;
21
+ readonly detail: {
22
+ readonly key: string;
23
+ readonly existingDigest?: string;
24
+ readonly incomingDigest?: string;
25
+ };
26
+ }
27
+
28
+ function equalBytes(left: Uint8Array, right: Uint8Array): boolean {
29
+ return left.length === right.length && left.every((value, index) => value === right[index]);
30
+ }
31
+
32
+ export class MaterialArtifactRegistry {
33
+ readonly #artifacts = new Map<string, MaterialRuntimeArtifact>();
34
+
35
+ register(
36
+ artifact: MaterialRuntimeArtifact,
37
+ ): Result<MaterialRuntimeArtifact, MaterialArtifactConflictError> {
38
+ const previous = this.#artifacts.get(artifact.key);
39
+ if (previous !== undefined) {
40
+ if (equalBytes(previous.bytes, artifact.bytes)) return ok(previous);
41
+ return err({
42
+ code: 'material-artifact-conflict',
43
+ expected: 'one immutable artifact byte sequence per specialization key',
44
+ hint: 're-cook the conflicting specialization and publish one artifact digest',
45
+ detail: {
46
+ key: artifact.key,
47
+ ...(previous.digest ? { existingDigest: previous.digest } : {}),
48
+ ...(artifact.digest ? { incomingDigest: artifact.digest } : {}),
49
+ },
50
+ });
51
+ }
52
+ const immutable = Object.freeze(artifact);
53
+ this.#artifacts.set(artifact.key, immutable);
54
+ return ok(immutable);
55
+ }
56
+
57
+ get(key: string): MaterialRuntimeArtifact | undefined {
58
+ return this.#artifacts.get(key);
59
+ }
60
+
61
+ inspect(key: string): MaterialArtifactInspection | undefined {
62
+ const artifact = this.#artifacts.get(key);
63
+ if (artifact === undefined) return undefined;
64
+ return {
65
+ key: artifact.key,
66
+ ...(artifact.digest === undefined ? {} : { digest: artifact.digest }),
67
+ byteLength: artifact.bytes.byteLength,
68
+ ...(artifact.metadata === undefined ? {} : { metadata: artifact.metadata }),
69
+ };
70
+ }
71
+ }
72
+
73
+ export function registerMaterialArtifact(
74
+ registry: MaterialArtifactRegistry,
75
+ artifact: MaterialRuntimeArtifact,
76
+ ): Result<MaterialRuntimeArtifact, MaterialArtifactConflictError> {
77
+ return registry.register(artifact);
78
+ }
@@ -0,0 +1,26 @@
1
+ import type { BindGroupLayoutDescriptor } from '@forgeax/engine-types';
2
+
3
+ export interface MaterialShaderArtifact {
4
+ readonly material: string;
5
+ readonly pass: string;
6
+ readonly wgsl: string;
7
+ readonly layoutIdentity: string;
8
+ readonly bindings: readonly BindGroupLayoutDescriptor[];
9
+ readonly deps: readonly string[];
10
+ readonly vertexInputs: readonly Readonly<Record<string, unknown>>[];
11
+ readonly specializationKey?: string;
12
+ }
13
+
14
+ export function isMaterialShaderArtifact(value: unknown): value is MaterialShaderArtifact {
15
+ if (value === null || typeof value !== 'object') return false;
16
+ const artifact = value as Partial<MaterialShaderArtifact>;
17
+ return (
18
+ typeof artifact.material === 'string' &&
19
+ typeof artifact.pass === 'string' &&
20
+ typeof artifact.wgsl === 'string' &&
21
+ typeof artifact.layoutIdentity === 'string' &&
22
+ Array.isArray(artifact.bindings) &&
23
+ Array.isArray(artifact.deps) &&
24
+ Array.isArray(artifact.vertexInputs)
25
+ );
26
+ }
@@ -0,0 +1,48 @@
1
+ import type { ParamSchemaEntry } from '@forgeax/engine-types';
2
+
3
+ export const STANDARD_PBR_ALPHA_CUTOFF_DEFAULT = 0;
4
+
5
+ /** Shared material contract for the standard PBR and skinned PBR shaders. */
6
+ export const DEFAULT_STANDARD_PBR_PARAM_SCHEMA: readonly ParamSchemaEntry[] = [
7
+ { name: 'baseColor', type: 'color', default: [1, 1, 1, 1] },
8
+ { name: 'metallic', type: 'f32', default: 0 },
9
+ { name: 'roughness', type: 'f32', default: 0.5 },
10
+ { name: 'metallicChannel', type: 'f32', default: 2 },
11
+ { name: 'roughnessChannel', type: 'f32', default: 1 },
12
+ { name: 'aoChannel', type: 'f32', default: 0 },
13
+ { name: 'extraChannel', type: 'f32', default: 0 },
14
+ { name: 'emissive', type: 'vec3', colorSpace: 'srgb', default: [0, 0, 0] },
15
+ { name: 'emissiveIntensity', type: 'f32', default: 0 },
16
+ { name: 'occlusionStrength', type: 'f32', default: 1 },
17
+ { name: 'alphaCutoff', type: 'f32', default: STANDARD_PBR_ALPHA_CUTOFF_DEFAULT },
18
+ { name: 'clearcoat', type: 'f32', default: 0 },
19
+ { name: 'clearcoatRoughness', type: 'f32', default: 0.5 },
20
+ { name: 'specularTint', type: 'vec3', colorSpace: 'srgb', default: [1, 1, 1] },
21
+ { name: 'normalScale', type: 'f32', default: 1 },
22
+ { name: 'baseColorTexture', type: 'texture2d' },
23
+ { name: 'metallicRoughnessTexture', type: 'texture2d' },
24
+ { name: 'normalTexture', type: 'texture2d' },
25
+ { name: 'specularTintTexture', type: 'texture2d' },
26
+ { name: 'emissiveTexture', type: 'texture2d' },
27
+ { name: 'occlusionTexture', type: 'texture2d' },
28
+ ];
29
+
30
+ export const DEFAULT_UNLIT_PARAM_SCHEMA: readonly ParamSchemaEntry[] = [
31
+ { name: 'baseColor', type: 'color', default: [1, 1, 1, 1] },
32
+ { name: 'alphaCutoff', type: 'f32', default: 0 },
33
+ { name: 'baseColorTexture', type: 'texture2d' },
34
+ ];
35
+
36
+ export const DEFAULT_SPRITE_PARAM_SCHEMA: readonly ParamSchemaEntry[] = [
37
+ { name: 'colorTint', type: 'vec4', colorSpace: 'srgb', default: [1, 1, 1, 1] },
38
+ { name: 'region', type: 'vec4', default: [0, 0, 1, 1] },
39
+ { name: 'pivotAndSize', type: 'vec4', default: [0.5, 0.5, 1, 1] },
40
+ { name: 'slicesAndMode', type: 'vec4', default: [0, 0, 0, 0] },
41
+ { name: 'baseColorTexture', type: 'texture2d' },
42
+ ];
43
+
44
+ export const DEFAULT_MSDF_TEXT_PARAM_SCHEMA: readonly ParamSchemaEntry[] = [
45
+ { name: 'tintColor', type: 'color', default: [1, 1, 1, 1] },
46
+ { name: 'distanceRange', type: 'vec4', default: [4, 512, 512, 0] },
47
+ { name: 'baseColorTexture', type: 'texture2d' },
48
+ ];
@@ -0,0 +1,208 @@
1
+ #pragma variant_axis STORAGE_BUFFER_AVAILABLE
2
+
3
+ #import forgeax_view::common::{View, FogViewParams, FogRay, Mesh, view, meshes, sampleMaterialTexture}
4
+ #import forgeax_view::fog::{apply_fog}
5
+
6
+ // @forgeax/engine-shader - msdf-text.wgsl
7
+ // (feat-20260531-world-space-msdf-text-rendering M5 / w20).
8
+ //
9
+ // World-space MSDF text material -- the 5th MaterialAsset shader after
10
+ // 'unlit' / 'standard' / 'sprite'. Text entities are baked into a standard
11
+ // mesh entity (glyph quads in local layout space; position.xy = layout x/y,
12
+ // position.z = 0; uv = atlas UV) and ride the existing forward path via
13
+ // `materialShaderId='forgeax::msdf-text'` (D-7 -- zero new pipelineTag,
14
+ // reuses the 'unlit' pipeline tag + transparent bucket + premultiplied
15
+ // alpha blend ONE / ONE_MINUS_SRC_ALPHA).
16
+ //
17
+ // Anchors:
18
+ // - plan-strategy D-3 (GPU billboard; WGSL has no inverse(), camera basis
19
+ // reconstructed without inverse() -- Finding 5/6/7)
20
+ // - plan-strategy D-7 (premultiplied output vec4(tint.rgb*alpha, alpha);
21
+ // ONE / ONE_MINUS_SRC_ALPHA; writes hdrColor so bloom catches it -- R-7)
22
+ // - knowledge-base/wiki/msdf-text-rendering.md section 6 (median + screenPxRange
23
+ // SSOT) + section 2 (median-of-three corner preservation)
24
+ // - requirements AC-10 (GPU billboard not degenerate to a line) + AC-12
25
+ // (bloom / premultiplied) + C3 (materialShaderId path, zero pipelineTag)
26
+ // + C4 (billboard x pick orientation independence)
27
+ //
28
+ // Billboard construction (D-3 / Finding 5/6/7):
29
+ // The mesh vertex.pos.xy carries the per-glyph layout coordinate (already
30
+ // includes intra-line advance + multi-line \n offset baked by the layout
31
+ // system). The quad is expanded along the camera's right / up basis vectors
32
+ // in WORLD space so the text always faces the camera (Finding 7: the quad
33
+ // normal is the camera forward direction, so "text plane parallel to view
34
+ // ray" never happens -- no NaN / flip flicker). WGSL exposes no inverse()
35
+ // built-in (Finding 5); the camera right / up are reconstructed from the
36
+ // world-space anchor and `view.cameraPos` (a look-at frame) with only
37
+ // normalize / cross -- zero matrix inverse, zero per-frame CPU rewrite of
38
+ // the mesh. `anchor` = the model matrix translation column
39
+ // (meshes[idx].worldFromLocal column 3).
40
+ //
41
+ // Bindings (mirror sprite.wgsl / unlit.wgsl byte-for-byte so the shared
42
+ // 4-BindGroupLayout chain is reused without a per-pipeline BGL; the
43
+ // metallicRoughness / normal slots 3..6 stay declared-but-unused, bound to
44
+ // pipelineState defaults at the host side -- D-1 candidate b):
45
+ //
46
+ // @group(0) @binding(0) view uniform (msdf-text reads
47
+ // worldViewProj +
48
+ // cameraPos)
49
+ // @group(1) @binding(0) material uniform (tintColor vec4
50
+ // + distanceRange
51
+ // f32 + atlas dims)
52
+ // @group(1) @binding(1) baseColorSampler sampler (atlas sampler)
53
+ // @group(1) @binding(2) baseColorTexture texture_2d<f32> (MSDF atlas)
54
+ // @group(2) @binding(0) meshes storage (msdf-text reads
55
+ // meshes[idx]
56
+ // .worldFromLocal
57
+ // translation
58
+ // column = anchor)
59
+
60
+ struct Material {
61
+ // tintColor: per-text color multiplied onto the reconstructed coverage
62
+ // alpha. Maps to the `tintColor` paramSchema entry (default opaque white).
63
+ tintColor : vec4<f32>,
64
+ // distanceRange: atlas-space distance-field width (msdfgen -pxrange output,
65
+ // = sidecar.common.distanceRange). atlasSize.xy carries the atlas pixel
66
+ // dimensions so screenPxRange can convert atlas units to screen pixels.
67
+ // distanceRange in .x; atlasSize in .yz; .w padding (std140 vec4 align).
68
+ distanceRange : vec4<f32>,
69
+ baseColorTextureCoordinatesTransform : vec4<f32>,
70
+ baseColorTextureCoordinatesMetadata : vec4<f32>,
71
+ };
72
+
73
+ @group(1) @binding(0) var<uniform> material : Material;
74
+ @group(1) @binding(1) var baseColorSampler : sampler;
75
+ @group(1) @binding(2) var baseColorTexture : texture_2d<f32>;
76
+ // Preserve filtering reflection for the bound texture passed to the helper.
77
+ fn materialTextureFilteringWitness() {
78
+ let base = baseColorTexture;
79
+ let baseWitness = textureSample(base, baseColorSampler, vec2<f32>(0.0));
80
+ }
81
+
82
+ struct VsIn {
83
+ @location(0) pos : vec3<f32>,
84
+ @location(1) normal : vec3<f32>,
85
+ @location(2) uv : vec2<f32>,
86
+ @location(3) tangent : vec4<f32>,
87
+ };
88
+
89
+ struct VsOut {
90
+ @builtin(position) clip : vec4<f32>,
91
+ @location(0) uv : vec2<f32>,
92
+ @location(1) worldPos : vec3<f32>,
93
+ };
94
+
95
+ @vertex
96
+ fn vs_main(in : VsIn, @builtin(instance_index) idx : u32) -> VsOut {
97
+ // anchor = the entity world-matrix translation column (D-3). The layout
98
+ // system bakes the glyph quads in local layout space (Y-up), so the model
99
+ // matrix is a pure Translate (Rotate / Scale folded into the layout) and
100
+ // its 4th column is the world-space anchor.
101
+ let model = meshes[idx].worldFromLocal;
102
+ let anchor = model[3].xyz;
103
+ // Camera look-at frame (Finding 5/6/7): forward = anchor -> camera. WGSL
104
+ // has no inverse() built-in; the right / up basis is reconstructed from
105
+ // `view.cameraPos` + the world up reference (0,1,0) with normalize / cross
106
+ // only -- zero matrix inverse, the quad normal is always the camera
107
+ // forward direction so the billboard never degenerates to a line.
108
+ let forward = normalize(view.cameraPos - anchor);
109
+ // Guard against the camera looking straight down/up the world-up axis
110
+ // (forward parallel to (0,1,0)): fall back to a +Z up reference so cross()
111
+ // stays well-conditioned. This is the only degenerate case (Finding 7) and
112
+ // is resolved here rather than producing a NaN basis.
113
+ let upRef = select(
114
+ vec3<f32>(0.0, 1.0, 0.0),
115
+ vec3<f32>(0.0, 0.0, 1.0),
116
+ abs(forward.y) > 0.999,
117
+ );
118
+ let right = normalize(cross(upRef, forward));
119
+ let up = cross(forward, right);
120
+ // Expand the glyph quad's local layout coordinate along the world-space
121
+ // right / up basis (Finding 6: billboard-rotate then translate). pos.z is
122
+ // 0 for every baked glyph vertex so it contributes nothing.
123
+ let world_pos = anchor + right * in.pos.x + up * in.pos.y;
124
+ var out : VsOut;
125
+ out.clip = view.worldViewProj * vec4<f32>(world_pos, 1.0);
126
+ out.uv = in.uv;
127
+ out.worldPos = world_pos;
128
+ return out;
129
+ }
130
+
131
+ // median(R, G, B): corner-preserving MSDF reconstruction operator (wiki
132
+ // section 2). Picks the distance supported by two-or-more edges so sharp
133
+ // corners survive bilinear upscaling (median = max(min, min(max)) form).
134
+ fn median(r : f32, g : f32, b : f32) -> f32 {
135
+ return max(min(r, g), min(max(r, g), b));
136
+ }
137
+
138
+ // screenPxRange: converts the atlas-space distanceRange into a screen-pixel
139
+ // ramp width using fwidth() of the atlas UV (wiki section 3.3 / section 6). The
140
+ // `max(., 1.0)` floor keeps the anti-aliasing ramp at least half a pixel wide
141
+ // (msdfgen README: ramp < 1 px degrades back to a hard step / aliasing).
142
+ fn screen_px_range(uv : vec2<f32>) -> f32 {
143
+ let atlas_dims = material.distanceRange.yz;
144
+ let unit_range = vec2<f32>(material.distanceRange.x) / atlas_dims;
145
+ let screen_tex_size = vec2<f32>(1.0) / fwidth(uv);
146
+ return max(0.5 * dot(unit_range, screen_tex_size), 1.0);
147
+ }
148
+
149
+ fn applySceneFog(viewParams : View, color : vec3<f32>, alpha : f32, worldPos : vec3<f32>) -> vec4<f32> {
150
+ var origin = viewParams.cameraPos;
151
+ var direction = normalize(worldPos - origin);
152
+ var rayDistance = length(worldPos - origin);
153
+ if (viewParams.temporalProjection.z >= 0.5) {
154
+ let nearH = viewParams.inverseViewProj * vec4<f32>(0.0, 0.0, 0.0, 1.0);
155
+ let farH = viewParams.inverseViewProj * vec4<f32>(0.0, 0.0, 1.0, 1.0);
156
+ let nearPoint = nearH.xyz / nearH.w;
157
+ let farPoint = farH.xyz / farH.w;
158
+ direction = normalize(farPoint - nearPoint);
159
+ origin = worldPos - direction * dot(worldPos - viewParams.cameraPos, direction);
160
+ rayDistance = max(dot(worldPos - origin, direction), 0.0);
161
+ }
162
+ return apply_fog(viewParams.fog, FogRay(origin, direction, rayDistance), vec4<f32>(color, alpha));
163
+ }
164
+
165
+ // fs_main_hdr: outputs linear premultiplied alpha for the rgba16float
166
+ // offscreen target (D-7 / R-7). The tonemap fullscreen pass handles sRGB
167
+ // encoding; writing hdrColor lets the bloom bright-pass catch the text.
168
+ @fragment
169
+ fn fs_main_hdr(in : VsOut) -> @location(0) vec4<f32> {
170
+ let msd = sampleMaterialTexture(baseColorTexture, baseColorSampler, in.uv, material.baseColorTextureCoordinatesMetadata.zw).rgb;
171
+ let sd = median(msd.r, msd.g, msd.b);
172
+ // wiki section 3.3: opacity = clamp((sd - 0.5) * screenPxRange + 0.5, 0, 1)
173
+ // (linear ramp; equivalent to smoothstep(0.5 - delta, 0.5 + delta, sd) at
174
+ // sub-pixel error -- wiki section 3.2 form-equivalence callout).
175
+ let dist = (sd - 0.5) * screen_px_range(in.uv);
176
+ let alpha = clamp(dist + 0.5, 0.0, 1.0) * material.tintColor.a;
177
+ // Premultiplied output: rgb already multiplied by alpha for srcFactor=ONE /
178
+ // dstFactor=ONE_MINUS_SRC_ALPHA (wiki section 6 SSOT).
179
+ let fogged = applySceneFog(view, material.tintColor.rgb, alpha, in.worldPos);
180
+ return vec4<f32>(fogged.rgb * fogged.a, fogged.a);
181
+ }
182
+
183
+ // linear_to_srgb: per-channel IEC 61966-2-1 transfer function for the LDR
184
+ // bgra8unorm swap-chain target (not hardware-sRGB-encoded). Alpha is NOT
185
+ // encoded -- the blend equation operates on raw alpha.
186
+ fn linear_to_srgb(linear : f32) -> f32 {
187
+ let c = clamp(linear, 0.0, 1.0);
188
+ return select(c * 12.92, pow(c, 1.0 / 2.4) * 1.055 - 0.055, c > 0.0031308);
189
+ }
190
+
191
+ // fs_main: LDR variant for the bgra8unorm target. Same coverage math as the
192
+ // HDR variant; encodes RGB through the sRGB transfer function (the LDR target
193
+ // is not hardware-sRGB-encoded), alpha stays linear through the blend.
194
+ @fragment
195
+ fn fs_main(in : VsOut) -> @location(0) vec4<f32> {
196
+ let msd = sampleMaterialTexture(baseColorTexture, baseColorSampler, in.uv, material.baseColorTextureCoordinatesMetadata.zw).rgb;
197
+ let sd = median(msd.r, msd.g, msd.b);
198
+ let dist = (sd - 0.5) * screen_px_range(in.uv);
199
+ let alpha = clamp(dist + 0.5, 0.0, 1.0) * material.tintColor.a;
200
+ let fogged = applySceneFog(view, material.tintColor.rgb, alpha, in.worldPos);
201
+ let premult = fogged.rgb * fogged.a;
202
+ return vec4<f32>(
203
+ linear_to_srgb(premult.r),
204
+ linear_to_srgb(premult.g),
205
+ linear_to_srgb(premult.b),
206
+ fogged.a,
207
+ );
208
+ }