@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,307 @@
1
+ #pragma variant_axis STORAGE_BUFFER_AVAILABLE
2
+ #define_import_path forgeax_material::sprite-lit
3
+
4
+ #import forgeax_view::common::{View, FogViewParams, FogRay, Mesh, InstanceData, PointLight, SpotLight, view, meshes, instances, pointLightsBuffer, spotLightsBuffer, sampleMaterialTexture, packSceneTemporal}
5
+ #import forgeax_view::fog::{apply_fog}
6
+
7
+ // @forgeax/engine-shader - sprite-lit.wgsl
8
+ // (tweak-20260701-sprite-lit-flat-default-drop-ndotl-for-2d).
9
+ //
10
+ // Parallel 4th sprite shading model (alongside forgeax::sprite / unlit /
11
+ // pbr-standard). Lights a 2D sprite quad with the 3 punctual light kinds
12
+ // (DirectionalLight + PointLight + SpotLight) using flat 2D lighting:
13
+ // each fragment sums `albedo * lightColor * attenuation * cone` across all
14
+ // active lights. Sprites are treated as omnidirectional receivers so light
15
+ // direction and camera orientation do not affect shading, matching the
16
+ // Godot Light2D and Unity URP 2D Renderer mental model.
17
+ //
18
+ // SSOTs:
19
+ // - requirements section 2 (in-scope items)
20
+ // - requirements section 4 AC-01 .. AC-07
21
+ // - plan-strategy D-P1 (VsOut carries worldPos from vertex stage)
22
+ // - plan-strategy D-P2 (light functions drop normal parameter; flat SSOT)
23
+ //
24
+ // What this shader is NOT:
25
+ // - no normal-map sampling
26
+ // - no shadow receiver / caster
27
+ // - no Godot-style light_mode blends
28
+ // - no IBL ambient
29
+ // - no HDRP cluster-forward path
30
+ //
31
+ // >>> AI user error self-recovery hint:
32
+ // sprite-lit needs at least one light in scene; for unlit sprites use
33
+ // forgeax::sprite (the engine ships them as parallel 4th shading model id;
34
+ // 1 string change in MaterialAsset.passes[0].shader).
35
+ //
36
+ // Bindings (byte-identical to sprite.wgsl so the 4 BindGroupLayout chain is
37
+ // reused without a per-pipeline BGL; the 4 PBR-unused entries 3..6 bind
38
+ // pipelineState.defaultSampler + .defaultWhiteTextureView on the host side):
39
+ //
40
+ // @group(0) @binding(0) view uniform
41
+ // @group(0) @binding(1) pointLightsBuffer storage / uniform (variant)
42
+ // @group(0) @binding(2) spotLightsBuffer storage / uniform (variant)
43
+ // @group(1) @binding(0) material uniform (sprite layout
44
+ // colorTint /
45
+ // region /
46
+ // pivotAndSize /
47
+ // slicesAndMode)
48
+ // @group(1) @binding(1) baseColorSampler sampler
49
+ // @group(1) @binding(2) baseColorTexture texture_2d<f32>
50
+ // @group(2) @binding(0) meshes storage
51
+ // @group(3) @binding(0) instances storage (per-instance
52
+ // localFromInstance
53
+ // mat4)
54
+
55
+ struct Material {
56
+ // .xyz multiplies textureSample.rgb; .w multiplies textureSample.a.
57
+ // Mirrors sprite.wgsl Material layout byte-for-byte (sprite-lit reuses
58
+ // the same UBO writer + paramSchema entries).
59
+ colorTint : vec4<f32>,
60
+ region : vec4<f32>,
61
+ pivotAndSize : vec4<f32>,
62
+ slicesAndMode : vec4<f32>,
63
+ baseColorTextureCoordinatesTransform : vec4<f32>,
64
+ baseColorTextureCoordinatesMetadata : vec4<f32>,
65
+ };
66
+
67
+ @group(1) @binding(0) var<uniform> material : Material;
68
+ @group(1) @binding(1) var baseColorSampler : sampler;
69
+ @group(1) @binding(2) var baseColorTexture : texture_2d<f32>;
70
+ // Preserve filtering reflection for the bound texture passed to the helper.
71
+ fn materialTextureFilteringWitness() {
72
+ let base = baseColorTexture;
73
+ let baseWitness = textureSample(base, baseColorSampler, vec2<f32>(0.0));
74
+ }
75
+
76
+ struct VsIn {
77
+ @location(0) pos : vec3<f32>,
78
+ @location(1) normal : vec3<f32>,
79
+ @location(2) uv : vec2<f32>,
80
+ @location(3) tangent : vec4<f32>,
81
+ };
82
+
83
+ // VsOut carries the atlas UV plus the per-fragment world-space position.
84
+ // The world position is emitted from the vertex stage (already computed
85
+ // there for `out.clip`), so PointLight / SpotLight attenuation gets an
86
+ // exact per-fragment worldPos through interpolation. This replaces the
87
+ // earlier fragment-side reconstruction path, which collapsed under
88
+ // multi-instance draws and could not handle the 9-slice quad layout.
89
+ struct VsOut {
90
+ @builtin(position) clip : vec4<f32>,
91
+ @location(0) uv_atlas : vec2<f32>,
92
+ @location(1) worldPos : vec3<f32>,
93
+ };
94
+
95
+ struct SpriteVertex {
96
+ posLocal : vec3<f32>,
97
+ uvAtlas : vec2<f32>,
98
+ };
99
+
100
+ fn resolveSpriteVertex(in : VsIn, vertex_index : u32) -> SpriteVertex {
101
+ // Body is the slice-aware sprite vertex stage byte-for-byte ported from
102
+ // sprite.wgsl (feat-20260520 M-3 / w19 + feat-20260527 M3 / w15). The
103
+ // 9-slice early-out via slicesAndMode == 0 sentinel keeps the legacy
104
+ // single-quad path byte-identical so sprite-lit demos that do not enable
105
+ // slicing pay zero perf for the slicing branch.
106
+ let pivot = material.pivotAndSize.xy;
107
+ let size = material.pivotAndSize.zw;
108
+ let useSlices = any(material.slicesAndMode != vec4<f32>(0.0));
109
+ var pos_local : vec3<f32>;
110
+ var uv_atlas : vec2<f32>;
111
+ if (useSlices) {
112
+ let abs_slices = abs(material.slicesAndMode);
113
+ let is_tile = material.slicesAndMode.w < 0.0;
114
+ let i = vertex_index % 4u;
115
+ let j = vertex_index / 4u;
116
+ let u_pos_arr = array<f32, 4>(0.0, abs_slices.x, 1.0 - abs_slices.z, 1.0);
117
+ let v_pos_arr = array<f32, 4>(0.0, abs_slices.y, 1.0 - abs_slices.w, 1.0);
118
+ let u_pos = u_pos_arr[i];
119
+ let v_pos_top = v_pos_arr[j];
120
+ let v_pos_eff = 1.0 - v_pos_top;
121
+ pos_local = vec3<f32>((u_pos - pivot.x) * size.x, (v_pos_eff - pivot.y) * size.y, 0.0);
122
+ var u_uv_arr = array<f32, 4>(0.0, abs_slices.x, 1.0 - abs_slices.z, 1.0);
123
+ var v_uv_arr = array<f32, 4>(0.0, abs_slices.y, 1.0 - abs_slices.w, 1.0);
124
+ if (is_tile) {
125
+ let mid_u = 1.0 - abs_slices.x - abs_slices.z;
126
+ let mid_v = 1.0 - abs_slices.y - abs_slices.w;
127
+ u_uv_arr[2] = abs_slices.x + 2.0 * mid_u;
128
+ u_uv_arr[3] = u_uv_arr[2] + abs_slices.z;
129
+ v_uv_arr[2] = abs_slices.y + 2.0 * mid_v;
130
+ v_uv_arr[3] = v_uv_arr[2] + abs_slices.w;
131
+ }
132
+ let uv_u = u_uv_arr[i];
133
+ let uv_v_top = v_uv_arr[j];
134
+ let uv_v_eff = 1.0 - uv_v_top;
135
+ uv_atlas = vec2<f32>(uv_u, uv_v_eff) * material.region.zw + material.region.xy;
136
+ } else {
137
+ let uv_eff = vec2<f32>(in.uv.x, 1.0 - in.uv.y);
138
+ pos_local = vec3<f32>((uv_eff - pivot) * size, 0.0);
139
+ uv_atlas = uv_eff * material.region.zw + material.region.xy;
140
+ }
141
+ var out : SpriteVertex;
142
+ out.posLocal = pos_local;
143
+ out.uvAtlas = uv_atlas;
144
+ return out;
145
+ }
146
+
147
+ @vertex
148
+ fn vs_main(in : VsIn, @builtin(instance_index) idx : u32, @builtin(vertex_index) vertex_index : u32) -> VsOut {
149
+ let vertex = resolveSpriteVertex(in, vertex_index);
150
+ // AC-11 instances path day-1: the world transform is meshes[0].worldFromLocal
151
+ // * instances[idx].localFromInstance * vec4(pos_local, 1.0) -- same chain
152
+ // sprite.wgsl + default-standard-pbr.wgsl use.
153
+ let world = meshes[0].worldFromLocal * instances[idx].localFromInstance * vec4<f32>(vertex.posLocal, 1.0);
154
+ var out : VsOut;
155
+ out.clip = view.worldViewProj * world;
156
+ out.uv_atlas = vertex.uvAtlas;
157
+ out.worldPos = world.xyz;
158
+ return out;
159
+ }
160
+
161
+ // linear_to_srgb mirrors sprite.wgsl L251-254: per-channel IEC 61966-2-1
162
+ // transfer used by the LDR fragment entry to encode the bgra8unorm swap
163
+ // chain output (the format is not hardware-sRGB-encoded; the shader does it).
164
+ fn linear_to_srgb(linear : f32) -> f32 {
165
+ let c = clamp(linear, 0.0, 1.0);
166
+ return select(c * 12.92, pow(c, 1.0 / 2.4) * 1.055 - 0.055, c > 0.0031308);
167
+ }
168
+
169
+ // Flat directional contribution. Every fragment receives the full
170
+ // `view.lightColor` modulated by `albedo`. Direction of the light is
171
+ // intentionally ignored -- 2D sprites lie in the XY plane facing the camera
172
+ // and are treated as omnidirectional receivers.
173
+ fn spriteLitDirectional(albedo : vec3<f32>) -> vec3<f32> {
174
+ return albedo * view.lightColor;
175
+ }
176
+
177
+ // Flat point-light contribution. Applies the KHR-lights-punctual smooth-
178
+ // window range attenuation only. `attenuation` is 0 outside the light's
179
+ // range (`invRangeSquared`) and 1 at the light center; the reciprocal-square
180
+ // term gives the physical falloff. URP cap = 4 point lights per pass.
181
+ fn spriteLitPoint(p : PointLight, worldPos : vec3<f32>, albedo : vec3<f32>) -> vec3<f32> {
182
+ let toLight = p.position - worldPos;
183
+ let dSquared = max(dot(toLight, toLight), 1e-4);
184
+ let factor = 1.0 - (dSquared * p.invRangeSquared) * (dSquared * p.invRangeSquared);
185
+ let attenuation = max(min(factor, 1.0), 0.0) / dSquared;
186
+ return albedo * p.colorTimesIntensity * attenuation;
187
+ }
188
+
189
+ // Flat spot-light contribution. Same range attenuation as spriteLitPoint,
190
+ // modulated by a smoothstep cone factor (host-side degree -> cosine
191
+ // conversion). Spot direction only shapes the cone; it does not project
192
+ // onto a surface normal. URP cap = 4 spot lights per pass.
193
+ fn spriteLitSpot(s : SpotLight, worldPos : vec3<f32>, albedo : vec3<f32>) -> vec3<f32> {
194
+ let toLight = s.position - worldPos;
195
+ let dSquared = max(dot(toLight, toLight), 1e-4);
196
+ let l = toLight / sqrt(dSquared);
197
+ let factor = 1.0 - (dSquared * s.invRangeSquared) * (dSquared * s.invRangeSquared);
198
+ let attenuation = max(min(factor, 1.0), 0.0) / dSquared;
199
+ let cone = smoothstep(s.cosOuter, s.cosInner, dot(l, -s.direction));
200
+ return albedo * s.colorTimesIntensity * attenuation * cone;
201
+ }
202
+
203
+ // Shared shading body used by both fs_main (LDR) and fs_main_hdr (HDR).
204
+ // The two entry points differ only in their tail-end clamp + srgb encode
205
+ // step (LDR clamp / HDR pass-through boundary).
206
+ fn spriteLitShadeAccum(albedo : vec3<f32>, worldPos : vec3<f32>) -> vec3<f32> {
207
+ // Directional contribution (1 light, View UBO).
208
+ var lit = spriteLitDirectional(albedo);
209
+ // Point-light contribution (URP cap = 4; PointLightsArray.count).
210
+ let pointCount = pointLightsBuffer.count;
211
+ for (var i : u32 = 0u; i < pointCount; i = i + 1u) {
212
+ let p = pointLightsBuffer.slots[i];
213
+ lit = lit + spriteLitPoint(p, worldPos, albedo);
214
+ }
215
+ // Spot-light contribution (URP cap = 4).
216
+ let spotCount = spotLightsBuffer.count;
217
+ for (var i : u32 = 0u; i < spotCount; i = i + 1u) {
218
+ let s = spotLightsBuffer.slots[i];
219
+ lit = lit + spriteLitSpot(s, worldPos, albedo);
220
+ }
221
+ return lit;
222
+ }
223
+
224
+ fn applySceneFog(viewParams : View, color : vec3<f32>, alpha : f32, worldPos : vec3<f32>) -> vec4<f32> {
225
+ var origin = viewParams.cameraPos;
226
+ var direction = normalize(worldPos - origin);
227
+ var rayDistance = length(worldPos - origin);
228
+ if (viewParams.temporalProjection.z >= 0.5) {
229
+ let nearH = viewParams.inverseViewProj * vec4<f32>(0.0, 0.0, 0.0, 1.0);
230
+ let farH = viewParams.inverseViewProj * vec4<f32>(0.0, 0.0, 1.0, 1.0);
231
+ let nearPoint = nearH.xyz / nearH.w;
232
+ let farPoint = farH.xyz / farH.w;
233
+ direction = normalize(farPoint - nearPoint);
234
+ origin = worldPos - direction * dot(worldPos - viewParams.cameraPos, direction);
235
+ rayDistance = max(dot(worldPos - origin, direction), 0.0);
236
+ }
237
+ return apply_fog(viewParams.fog, FogRay(origin, direction, rayDistance), vec4<f32>(color, alpha));
238
+ }
239
+
240
+ @fragment
241
+ fn fs_main(in : VsOut) -> @location(0) vec4<f32> {
242
+ let texel = sampleMaterialTexture(baseColorTexture, baseColorSampler, in.uv_atlas, material.baseColorTextureCoordinatesMetadata.zw);
243
+ let albedo4 = texel * material.colorTint;
244
+ let lit = spriteLitShadeAccum(albedo4.rgb, in.worldPos);
245
+ // Strict clamp 0..1 before premultiplied alpha multiply keeps the LDR
246
+ // path bounded even when a scene stacks many lights.
247
+ let lit_rgba = clamp(vec4<f32>(lit, albedo4.a), vec4<f32>(0.0), vec4<f32>(1.0));
248
+ let fogged = applySceneFog(view, lit_rgba.rgb, lit_rgba.a, in.worldPos);
249
+ let premult = vec4<f32>(fogged.rgb * fogged.a, fogged.a);
250
+ // LDR target is bgra8unorm; encode rgb via the sRGB transfer in-shader.
251
+ return vec4<f32>(
252
+ linear_to_srgb(premult.r),
253
+ linear_to_srgb(premult.g),
254
+ linear_to_srgb(premult.b),
255
+ premult.a,
256
+ );
257
+ }
258
+
259
+ @fragment
260
+ fn fs_main_hdr(in : VsOut) -> @location(0) vec4<f32> {
261
+ let texel = sampleMaterialTexture(baseColorTexture, baseColorSampler, in.uv_atlas, material.baseColorTextureCoordinatesMetadata.zw);
262
+ let albedo4 = texel * material.colorTint;
263
+ let lit = spriteLitShadeAccum(albedo4.rgb, in.worldPos);
264
+ // HDR variant: do NOT clamp the lit output to [0, 1]; let the tonemap
265
+ // pass absorb HDR values > 1. Alpha stays clamped because the premult
266
+ // math requires alpha in [0, 1].
267
+ let alpha = clamp(albedo4.a, 0.0, 1.0);
268
+ let fogged = applySceneFog(view, lit, alpha, in.worldPos);
269
+ return vec4<f32>(fogged.rgb * fogged.a, fogged.a);
270
+ }
271
+
272
+ struct TemporalVsOut {
273
+ @builtin(position) clip : vec4<f32>,
274
+ @location(0) uvAtlas : vec2<f32>,
275
+ @location(1) @interpolate(linear) currentClip : vec4<f32>,
276
+ @location(2) @interpolate(linear) previousClip : vec4<f32>,
277
+ };
278
+
279
+ @vertex
280
+ fn vs_temporal(in : VsIn, @builtin(instance_index) idx : u32, @builtin(vertex_index) vertex_index : u32) -> TemporalVsOut {
281
+ let vertex = resolveSpriteVertex(in, vertex_index);
282
+ let currentWorld = meshes[0].worldFromLocal *
283
+ instances[idx].localFromInstance * vec4<f32>(vertex.posLocal, 1.0);
284
+ var previousWorld = currentWorld;
285
+ #if STORAGE_BUFFER_AVAILABLE == true
286
+ previousWorld = meshes[0].previousWorldFromLocal *
287
+ instances[idx].previousLocalFromInstance * vec4<f32>(vertex.posLocal, 1.0);
288
+ #endif
289
+ var out : TemporalVsOut;
290
+ out.currentClip = view.temporalCurrentViewProj * currentWorld;
291
+ out.clip = out.currentClip;
292
+ out.previousClip = view.temporalPreviousViewProj * previousWorld;
293
+ out.uvAtlas = vertex.uvAtlas;
294
+ return out;
295
+ }
296
+
297
+ @fragment
298
+ fn fs_temporal(in : TemporalVsOut) -> @location(0) vec4<f32> {
299
+ let texel = sampleMaterialTexture(baseColorTexture, baseColorSampler, in.uvAtlas, material.baseColorTextureCoordinatesMetadata.zw);
300
+ let alpha = clamp(texel.a * material.colorTint.a, 0.0, 1.0);
301
+ if (alpha <= 0.0) {
302
+ discard;
303
+ }
304
+ return packSceneTemporal(in.currentClip, in.previousClip, 1.0);
305
+ }
306
+ // sprite-lit needs at least one light in the scene to be visible; for unlit
307
+ // sprites use forgeax::sprite (1 string change in MaterialAsset).
@@ -0,0 +1,365 @@
1
+ #define_import_path forgeax_material::sprite
2
+ #pragma variant_axis STORAGE_BUFFER_AVAILABLE
3
+ #pragma variant_axis PER_INSTANCE_REGION
4
+
5
+ #import forgeax_view::common::{View, FogViewParams, FogRay, Mesh, InstanceData, view, meshes, instances, sampleMaterialTexture, packSceneTemporal}
6
+ #import forgeax_view::fog::{apply_fog}
7
+
8
+ // @forgeax/engine-shader - sprite.wgsl (feat-20260520-2d-sprite-layer-mvp /
9
+ // M-3 / w19). 2D sprite material — third variant of the MaterialAsset
10
+ // closed union (after 'unlit' and 'standard'). Pairs with the sprite
11
+ // alpha-blend pipeline (LDR + HDR variants; w24 createRenderer registration).
12
+ //
13
+ // Anchors:
14
+ // - requirements §3 AC-04 (vertex pos_local = (uv - pivot) * size +
15
+ // uv_atlas single-mad Q7 origin+size; fragment textureSample *
16
+ // colorTint; sprite is unlit per Q14; HDR clamp 0..1 R6 mitigation)
17
+ // - requirements §2.1.D shader table
18
+ // - requirements §6 R5 mitigation (sprite shader must NOT read the
19
+ // directional-light fields of the View UBO — sprite is unlit per Q14,
20
+ // 9/9 industry-default; charter P4 consistent abstraction)
21
+ // - requirements §6 R6 mitigation (HDR alpha blend boundary; sprite
22
+ // fragment output strictly clamped to [0, 1] before premultiplied
23
+ // alpha multiply)
24
+ // - plan-strategy §2 D-1 candidate (b) (sprite material reuses the PBR
25
+ // 7-entry BindGroup layout; 4 unused entries 3..6 default to
26
+ // `pipelineState.defaultSampler` + `pipelineState.defaultWhiteTextureView`;
27
+ // 0 lines new code on the BindGroup placeholders, 4 lines binding ref)
28
+ // - plan-strategy §2 D-9 (sprite shares the 112 B std140 View UBO with
29
+ // PBR; do NOT split off a sprite-specific View — charter P4 abstraction
30
+ // uniformity; static reads of the directional-light fields simply
31
+ // never happen, GPU pays zero perf for the unused fields)
32
+ // - research §Finding C-1 (`common.wgsl:17-22` View struct + naga_oil
33
+ // 0.22 Composer #import resolves View / Mesh / view / meshes at compile
34
+ // time; ShaderRegistry manifest path loadEngineShaderEntries adds a
35
+ // 4th entry in w20)
36
+ //
37
+ // Material bindings are derived from the sprite paramSchema; view, mesh and
38
+ // instance groups remain shared with the ordinary material pipeline:
39
+ //
40
+ // @group(0) @binding(0) view uniform (sprite reads
41
+ // view.worldViewProj
42
+ // only)
43
+ // @group(1) @binding(0) material uniform (sprite layout:
44
+ // colorTint vec4
45
+ // + region vec4
46
+ // + pivotAndSize
47
+ // vec4; 48 B)
48
+ // @group(1) @binding(1) baseColorSampler sampler
49
+ // @group(1) @binding(2) baseColorTexture texture_2d<f32>
50
+ // @group(2) @binding(0) meshes storage (sprite reads
51
+ // meshes[0]
52
+ // .worldFromLocal
53
+ // only — normal
54
+ // matrix unused)
55
+ // @group(3) @binding(0) instances storage (per-instance
56
+ // localFromInstance
57
+ // mat4; see
58
+ // common.wgsl)
59
+ //
60
+ // SSOT:
61
+ // sprite is unlit; do NOT read the directional-light fields of the
62
+ // View UBO — sprite.wgsl 1+1 line hard rule above and below; R5
63
+ // mitigation.
64
+ //
65
+ // @reuses forgeax_view::common View (112 B std140) — w19 imports the same
66
+ // View struct PBR / unlit consume so the 4-BindGroupLayout chain is
67
+ // pipeline-pipeline shareable; w24 sprite alpha-blend pipeline reuses
68
+ // pipelineState.viewBindGroupLayout / .materialBindGroupLayout /
69
+ // .meshBindGroupLayout / .instancesBindGroupLayout without a new BGL.
70
+ // @reuses MaterialBindGroup 7-entry PBR layout (4 unused entries 3..6 bind
71
+ // pipelineState.defaultSampler + pipelineState.defaultWhiteTextureView —
72
+ // plan-strategy D-1 candidate (b); 0 lines new GPU resource, 4 lines new
73
+ // binding ref).
74
+ // @new-surface sprite fragment output strictly clamped to [0, 1] before
75
+ // premultiplied-alpha multiply (R6 HDR alpha blend boundary mitigation;
76
+ // the sprite path runs through the same HDR rgba16float target as PBR
77
+ // when the active camera carries `tonemap !== 'none'`, so unclamped
78
+ // output would otherwise feed NaN / Inf into the tonemap fullscreen pass).
79
+
80
+ struct Material {
81
+ // .xyz multiplies textureSample.rgb; .w multiplies textureSample.a.
82
+ // AI users supply via SpriteMaterialAsset.colorTint (default [1,1,1,1]).
83
+ colorTint : vec4<f32>,
84
+ // Atlas region: .xy = (uMin, vMin) origin, .zw = (uW, vH) size. Single
85
+ // mad uv_atlas = uv * region.zw + region.xy. Host folds flipX / flipY
86
+ // into region (flipX -> region.x += region.z; region.z = -region.z;
87
+ // analog for Y) so the shader does not need an extra flip uniform.
88
+ region : vec4<f32>,
89
+ // feat-20260625-refactor-sprite-as-transparent-mesh M3 / w11 (D-6):
90
+ // pivotAndSize.xy = pivot (0..1 normalised; (0,0)=top-left, (1,1)=
91
+ // bottom-left of texture UV space). The legacy .zw=size pair is now
92
+ // a DEAD SLOT — the sprite quad is a local-space unit quad (1x1) and
93
+ // world scale flows entirely through meshes[i].worldFromLocal, no
94
+ // longer double-applied by both the UBO size factor and the world
95
+ // matrix (research F-4 "scale^2 -> scale^1" correction). The .zw
96
+ // half stays in the struct so the std140 layout slot 2 is byte-stable
97
+ // for the generic UBO writer (plan-strategy D-2 derive(uboLayout)
98
+ // offsets); shaders MUST NOT read material.pivotAndSize.zw post-w11.
99
+ pivotAndSize : vec4<f32>,
100
+ // 9-slice placeholder (feat-20260527-sprite-nineslice M1 / w3): .xyz =
101
+ // L/T/R/B inset values folded into a single vec4 by the schema-driven
102
+ // UBO writer (M2), .w = mode signed-encoded (positive = stretch / 0,
103
+ // negative = tile per plan-strategy section D-3 sliceMode-on-vec4.w
104
+ // sentinel). M1 is placeholder-only — vs_main / fs_main do NOT read
105
+ // this field; the field exists so the std140 layout has slot 3 reserved
106
+ // at the wgsl side, letting the M2 record-stage writer hit a byte-stable
107
+ // offset (plan-strategy D-7 byte-stable UBO write contract).
108
+ // sprite UBO grows 48 B -> 64 B; legacy sprite renders byte-identically
109
+ // because slicesAndMode is never sampled in the vertex / fragment paths
110
+ // when slices=[0,0,0,0] (zero-branch GPU path).
111
+ slicesAndMode : vec4<f32>,
112
+ baseColorTextureCoordinatesTransform : vec4<f32>,
113
+ baseColorTextureCoordinatesMetadata : vec4<f32>,
114
+ };
115
+
116
+ @group(1) @binding(0) var<uniform> material : Material;
117
+ @group(1) @binding(1) var baseColorSampler : sampler;
118
+ @group(1) @binding(2) var baseColorTexture : texture_2d<f32>;
119
+ // Preserve filtering reflection for the bound texture passed to the helper.
120
+ fn materialTextureFilteringWitness() {
121
+ let base = baseColorTexture;
122
+ let baseWitness = textureSample(base, baseColorSampler, vec2<f32>(0.0));
123
+ }
124
+
125
+ struct VsIn {
126
+ @location(0) pos : vec3<f32>,
127
+ @location(1) normal : vec3<f32>,
128
+ @location(2) uv : vec2<f32>,
129
+ @location(3) tangent : vec4<f32>,
130
+ };
131
+
132
+ struct VsOut {
133
+ @builtin(position) clip : vec4<f32>,
134
+ // Atlas-space UV: already folded through region origin + size by the
135
+ // vertex stage so the fragment can sample the bound material texture at
136
+ // `in.uv_atlas` without any host-side region
137
+ // re-write per draw.
138
+ @location(0) uv_atlas : vec2<f32>,
139
+ @location(1) worldPos : vec3<f32>,
140
+ };
141
+
142
+ struct SpriteVertex {
143
+ posLocal : vec3<f32>,
144
+ uvAtlas : vec2<f32>,
145
+ };
146
+
147
+ fn resolveSpriteVertex(in : VsIn, idx : u32, vertex_index : u32) -> SpriteVertex {
148
+ // feat-20260625-refactor-sprite-as-transparent-mesh M3 / w11 (D-6):
149
+ // pos_local = (uv - pivot) * 1, NOT (uv - pivot) * size. The sprite quad
150
+ // is now a UNIT QUAD in local space ([-pivot, 1-pivot]) and world scale
151
+ // flows entirely through meshes[i].worldFromLocal. Pre-w11 the UBO's
152
+ // pivotAndSize.zw scaled the local quad AND the world matrix also scaled
153
+ // it, producing the scale^2 visual size debt that research F-4 flagged.
154
+ // Post-w11 there is one and only one scale source: the entity's
155
+ // Transform.world. The mesh's built-in vertex.pos is NOT consumed (sprite
156
+ // geometry is fully re-derived from uv + pivot) so HANDLE_QUAD's mesh.pos
157
+ // remains a placeholder driving vertex count (4) and topology only.
158
+ //
159
+ // Compensation: since bug-20260601-procedural-geometry-uv-v-axis-vs-webgpu-sampler-ori
160
+ // M1 flipped procedural HANDLE_QUAD from bottom-left V (1-t) to top-left
161
+ // V (t) to match the WebGPU sampler convention, the sprite's uv-derived
162
+ // geometry must be restored to the pre-M1 bottom-left convention so that
163
+ // position, triangle winding (cull:back, frontFace:ccw), and atlas sampling
164
+ // are byte-identical. We define uv_eff = (in.uv.x, 1 - in.uv.y) and consume
165
+ // it in BOTH pos_local and uv_atlas below.
166
+ let pivot = material.pivotAndSize.xy;
167
+
168
+ // feat-20260527-sprite-nineslice M3 / w15 (plan-strategy section D-3 + D-4):
169
+ // 9-region map. Early-out when slicesAndMode is the all-zero sentinel so
170
+ // the legacy single-quad path stays byte-identical for sprites without
171
+ // slices (zero-slice sprite users pay zero perf for the new feature). When
172
+ // useSlices fires, the 16-vertex HANDLE_NINESLICE_QUAD mesh is bound by
173
+ // the record stage (D-2); we reinterpret vertex_index as a 4x4 grid (i =
174
+ // column 0..3, j = row 0..3) and route each grid point through 4 anchor
175
+ // lanes:
176
+ // u_pos_arr / v_pos_arr -> position anchors (atlas-UV space, mapped to
177
+ // world via legacy `(u - pivot) * 1`)
178
+ // u_uv_arr / v_uv_arr -> atlas UV anchors (region-local, then folded
179
+ // through region.zw / region.xy)
180
+ // Stretch mode (slicesAndMode.w >= 0): u/v_uv_arr == u/v_pos_arr, the
181
+ // middle band UV stays in [slice, 1-slice]. Tile mode (slicesAndMode.w
182
+ // < 0): the middle UV anchors are pushed past 1.0 so the sampler with
183
+ // addressMode='repeat' (D-4 sampler.repeat path; D-9 register-time
184
+ // soft-warn flags missing repeat) wraps the middle band and re-emits
185
+ // the texture. Vertex shader does NOT call wgsl `fract` — the wrap is
186
+ // entirely sampler-driven (charter P3 + plan-strategy D-4 fract veto).
187
+ let useSlices = any(material.slicesAndMode != vec4<f32>(0.0));
188
+ var pos_local : vec3<f32>;
189
+ var uv_atlas : vec2<f32>;
190
+ if (useSlices) {
191
+ let abs_slices = abs(material.slicesAndMode);
192
+ let is_tile = material.slicesAndMode.w < 0.0;
193
+ let i = vertex_index % 4u;
194
+ let j = vertex_index / 4u;
195
+
196
+ // 4 anchors in atlas-UV space (top-left convention).
197
+ let u_pos_arr = array<f32, 4>(0.0, abs_slices.x, 1.0 - abs_slices.z, 1.0);
198
+ let v_pos_arr = array<f32, 4>(0.0, abs_slices.y, 1.0 - abs_slices.w, 1.0);
199
+ let u_pos = u_pos_arr[i];
200
+ let v_pos_top = v_pos_arr[j];
201
+ // Flip V to bottom-left convention before the legacy `(u - pivot) * 1`.
202
+ let v_pos_eff = 1.0 - v_pos_top;
203
+ pos_local = vec3<f32>(u_pos - pivot.x, v_pos_eff - pivot.y, 0.0);
204
+
205
+ // UV anchors. Stretch == position anchors. Tile pushes middle past 1.
206
+ var u_uv_arr = array<f32, 4>(0.0, abs_slices.x, 1.0 - abs_slices.z, 1.0);
207
+ var v_uv_arr = array<f32, 4>(0.0, abs_slices.y, 1.0 - abs_slices.w, 1.0);
208
+ if (is_tile) {
209
+ let mid_u = 1.0 - abs_slices.x - abs_slices.z;
210
+ let mid_v = 1.0 - abs_slices.y - abs_slices.w;
211
+ // One full retile across the middle band: anchor[2] = corner_left +
212
+ // 2 * mid; anchor[3] keeps the right-corner UV span attached.
213
+ u_uv_arr[2] = abs_slices.x + 2.0 * mid_u;
214
+ u_uv_arr[3] = u_uv_arr[2] + abs_slices.z;
215
+ v_uv_arr[2] = abs_slices.y + 2.0 * mid_v;
216
+ v_uv_arr[3] = v_uv_arr[2] + abs_slices.w;
217
+ }
218
+ let uv_u = u_uv_arr[i];
219
+ let uv_v_top = v_uv_arr[j];
220
+ let uv_v_eff = 1.0 - uv_v_top;
221
+ uv_atlas = vec2<f32>(uv_u, uv_v_eff) * material.region.zw + material.region.xy;
222
+ } else {
223
+ let uv_eff = vec2<f32>(in.uv.x, 1.0 - in.uv.y);
224
+ pos_local = vec3<f32>(uv_eff - pivot, 0.0);
225
+ // Q7 single-mad uv_atlas = uv * region.zw + region.xy. host folds
226
+ // flipX / flipY by negating region.zw + offsetting region.xy so this
227
+ // formula handles every flip case without a per-fragment branch.
228
+ //
229
+ // M2 compensation note: uv_eff restores the pre-M1 bottom-left V
230
+ // convention for the vertex stage. The host fold of flipY into region
231
+ // operates on atlas-space coordinates independently — no double-flip
232
+ // risk, because the region adjustment is a linear transform applied
233
+ // after uv_eff and neither path depends on the other's sign convention.
234
+ //
235
+ // feat-20260625-sprite-instances-and-tilemap-terrain-static-batch M2 /
236
+ // w7 (plan-strategy §2 D-5 + research §Q-R-4.4): when the sprite
237
+ // pipeline composes this module with PER_INSTANCE_REGION=true the
238
+ // region source is the per-instance value carried in
239
+ // `instances[idx].region` (host-uploaded 80 B-per-instance interleaved
240
+ // buffer; D-1 + D-9). When PER_INSTANCE_REGION is unset (legacy
241
+ // sprite-atlas / non-SpriteInstances entities) the region still comes
242
+ // from the material UBO. The 9-slice branch above keeps reading
243
+ // `material.region` regardless — 9-slice config is material-level by
244
+ // construction (OOS-3 + D-5) and the two region semantics do not mix.
245
+ var region_src = material.region;
246
+ #if PER_INSTANCE_REGION == true
247
+ region_src = instances[idx].region;
248
+ #endif
249
+ uv_atlas = uv_eff * region_src.zw + region_src.xy;
250
+ }
251
+
252
+ // feat-20260604-instances-per-instance-transform-shader-group3-bin M2 / w8:
253
+ // entity world from meshes[0] (dynamic-offset window already aimed at
254
+ // this entity), per-instance local from instances[idx].localFromInstance.
255
+ // sprite is unlit — normal matrix unused.
256
+ var out : SpriteVertex;
257
+ out.posLocal = pos_local;
258
+ out.uvAtlas = uv_atlas;
259
+ return out;
260
+ }
261
+
262
+ @vertex
263
+ fn vs_main(in : VsIn, @builtin(instance_index) idx : u32, @builtin(vertex_index) vertex_index : u32) -> VsOut {
264
+ let vertex = resolveSpriteVertex(in, idx, vertex_index);
265
+ let world = meshes[0].worldFromLocal * instances[idx].localFromInstance * vec4<f32>(vertex.posLocal, 1.0);
266
+ var out : VsOut;
267
+ out.clip = view.worldViewProj * world;
268
+ out.uv_atlas = vertex.uvAtlas;
269
+ out.worldPos = world.xyz;
270
+ return out;
271
+ }
272
+
273
+ fn applySceneFog(viewParams : View, color : vec3<f32>, alpha : f32, worldPos : vec3<f32>) -> vec4<f32> {
274
+ var origin = viewParams.cameraPos;
275
+ var direction = normalize(worldPos - origin);
276
+ var rayDistance = length(worldPos - origin);
277
+ if (viewParams.temporalProjection.z >= 0.5) {
278
+ let nearH = viewParams.inverseViewProj * vec4<f32>(0.0, 0.0, 0.0, 1.0);
279
+ let farH = viewParams.inverseViewProj * vec4<f32>(0.0, 0.0, 1.0, 1.0);
280
+ let nearPoint = nearH.xyz / nearH.w;
281
+ let farPoint = farH.xyz / farH.w;
282
+ direction = normalize(farPoint - nearPoint);
283
+ origin = worldPos - direction * dot(worldPos - viewParams.cameraPos, direction);
284
+ rayDistance = max(dot(worldPos - origin, direction), 0.0);
285
+ }
286
+ return apply_fog(viewParams.fog, FogRay(origin, direction, rayDistance), vec4<f32>(color, alpha));
287
+ }
288
+
289
+ // linear_to_srgb: per-channel IEC 61966-2-1 transfer function used by the
290
+ // LDR fragment entry to encode linear premultiplied RGB into the bgra8unorm
291
+ // swap-chain storage. The bgra8unorm target is not hardware-sRGB-encoded
292
+ // (unlike bgra8unorm-srgb), so the shader must supply the encoding.
293
+ // Alpha is NOT encoded -- the blend equation operates on raw alpha.
294
+ fn linear_to_srgb(linear : f32) -> f32 {
295
+ let c = clamp(linear, 0.0, 1.0);
296
+ return select(c * 12.92, pow(c, 1.0 / 2.4) * 1.055 - 0.055, c > 0.0031308);
297
+ }
298
+
299
+ @fragment
300
+ fn fs_main(in : VsOut) -> @location(0) vec4<f32> {
301
+ let texel = sampleMaterialTexture(baseColorTexture, baseColorSampler, in.uv_atlas, material.baseColorTextureCoordinatesMetadata.zw);
302
+ // R6 mitigation: strict clamp 0..1 before premultiplied alpha multiply.
303
+ let rgba = clamp(texel * material.colorTint, vec4<f32>(0.0), vec4<f32>(1.0));
304
+ // Premultiplied alpha: rgb pre-multiplied by alpha for srcFactor='one' /
305
+ // dstFactor='one-minus-src-alpha' blend.
306
+ let fogged = applySceneFog(view, rgba.rgb, rgba.a, in.worldPos);
307
+ let premult = vec4<f32>(fogged.rgb * fogged.a, fogged.a);
308
+ // LDR target is bgra8unorm (blendable per WebGPU spec); hardware does not
309
+ // sRGB-encode bgra8unorm, so apply the transfer function in the shader.
310
+ // Only RGB channels are encoded; alpha is linear throughout the blend.
311
+ return vec4<f32>(
312
+ linear_to_srgb(premult.r),
313
+ linear_to_srgb(premult.g),
314
+ linear_to_srgb(premult.b),
315
+ premult.a,
316
+ );
317
+ }
318
+
319
+ // HDR variant: outputs linear premultiplied alpha for the rgba16float
320
+ // offscreen target; the tonemap fullscreen pass handles sRGB encoding.
321
+ @fragment
322
+ fn fs_main_hdr(in : VsOut) -> @location(0) vec4<f32> {
323
+ let texel = sampleMaterialTexture(baseColorTexture, baseColorSampler, in.uv_atlas, material.baseColorTextureCoordinatesMetadata.zw);
324
+ // R6 mitigation: strict clamp 0..1 before premultiplied alpha multiply.
325
+ let rgba = clamp(texel * material.colorTint, vec4<f32>(0.0), vec4<f32>(1.0));
326
+ let fogged = applySceneFog(view, rgba.rgb, rgba.a, in.worldPos);
327
+ return vec4<f32>(fogged.rgb * fogged.a, fogged.a);
328
+ }
329
+
330
+ struct TemporalVsOut {
331
+ @builtin(position) clip : vec4<f32>,
332
+ @location(0) uvAtlas : vec2<f32>,
333
+ @location(1) @interpolate(linear) currentClip : vec4<f32>,
334
+ @location(2) @interpolate(linear) previousClip : vec4<f32>,
335
+ };
336
+
337
+ @vertex
338
+ fn vs_temporal(in : VsIn, @builtin(instance_index) idx : u32, @builtin(vertex_index) vertex_index : u32) -> TemporalVsOut {
339
+ let vertex = resolveSpriteVertex(in, idx, vertex_index);
340
+ let currentWorld = meshes[0].worldFromLocal *
341
+ instances[idx].localFromInstance * vec4<f32>(vertex.posLocal, 1.0);
342
+ var previousWorld = currentWorld;
343
+ #if STORAGE_BUFFER_AVAILABLE == true
344
+ previousWorld = meshes[0].previousWorldFromLocal *
345
+ instances[idx].previousLocalFromInstance * vec4<f32>(vertex.posLocal, 1.0);
346
+ #endif
347
+ var out : TemporalVsOut;
348
+ out.currentClip = view.temporalCurrentViewProj * currentWorld;
349
+ out.clip = out.currentClip;
350
+ out.previousClip = view.temporalPreviousViewProj * previousWorld;
351
+ out.uvAtlas = vertex.uvAtlas;
352
+ return out;
353
+ }
354
+
355
+ @fragment
356
+ fn fs_temporal(in : TemporalVsOut) -> @location(0) vec4<f32> {
357
+ let texel = sampleMaterialTexture(baseColorTexture, baseColorSampler, in.uvAtlas, material.baseColorTextureCoordinatesMetadata.zw);
358
+ let alpha = clamp(texel.a * material.colorTint.a, 0.0, 1.0);
359
+ if (alpha <= 0.0) {
360
+ discard;
361
+ }
362
+ return packSceneTemporal(in.currentClip, in.previousClip, 1.0);
363
+ }
364
+ // sprite is unlit; do NOT read the directional-light fields of the View
365
+ // UBO -- same SSOT hard rule as the header comment above; R5 mitigation.