reze-engine 0.11.2 → 0.12.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 (105) hide show
  1. package/README.md +59 -39
  2. package/dist/engine.d.ts +15 -2
  3. package/dist/engine.d.ts.map +1 -1
  4. package/dist/engine.js +159 -427
  5. package/dist/index.d.ts +1 -2
  6. package/dist/index.d.ts.map +1 -1
  7. package/dist/shaders/body.d.ts +1 -1
  8. package/dist/shaders/body.d.ts.map +1 -1
  9. package/dist/shaders/body.js +7 -28
  10. package/dist/shaders/cloth_rough.d.ts +1 -1
  11. package/dist/shaders/cloth_rough.d.ts.map +1 -1
  12. package/dist/shaders/cloth_rough.js +4 -16
  13. package/dist/shaders/cloth_smooth.d.ts +1 -1
  14. package/dist/shaders/cloth_smooth.d.ts.map +1 -1
  15. package/dist/shaders/cloth_smooth.js +5 -17
  16. package/dist/shaders/default.d.ts +1 -1
  17. package/dist/shaders/default.d.ts.map +1 -1
  18. package/dist/shaders/eye.d.ts +1 -1
  19. package/dist/shaders/eye.d.ts.map +1 -1
  20. package/dist/shaders/face.d.ts +1 -1
  21. package/dist/shaders/face.d.ts.map +1 -1
  22. package/dist/shaders/face.js +21 -57
  23. package/dist/shaders/hair.d.ts +1 -1
  24. package/dist/shaders/hair.d.ts.map +1 -1
  25. package/dist/shaders/hair.js +7 -27
  26. package/dist/shaders/materials/body.d.ts +2 -0
  27. package/dist/shaders/materials/body.d.ts.map +1 -0
  28. package/dist/shaders/materials/body.js +199 -0
  29. package/dist/shaders/materials/cloth_rough.d.ts +2 -0
  30. package/dist/shaders/materials/cloth_rough.d.ts.map +1 -0
  31. package/dist/shaders/materials/cloth_rough.js +178 -0
  32. package/dist/shaders/materials/cloth_smooth.d.ts +2 -0
  33. package/dist/shaders/materials/cloth_smooth.d.ts.map +1 -0
  34. package/dist/shaders/materials/cloth_smooth.js +174 -0
  35. package/dist/shaders/materials/default.d.ts +2 -0
  36. package/dist/shaders/materials/default.d.ts.map +1 -0
  37. package/dist/shaders/materials/default.js +171 -0
  38. package/dist/shaders/materials/eye.d.ts +2 -0
  39. package/dist/shaders/materials/eye.d.ts.map +1 -0
  40. package/dist/shaders/materials/eye.js +146 -0
  41. package/dist/shaders/materials/face.d.ts +2 -0
  42. package/dist/shaders/materials/face.d.ts.map +1 -0
  43. package/dist/shaders/materials/face.js +199 -0
  44. package/dist/shaders/materials/hair.d.ts +2 -0
  45. package/dist/shaders/materials/hair.d.ts.map +1 -0
  46. package/dist/shaders/materials/hair.js +185 -0
  47. package/dist/shaders/materials/metal.d.ts +2 -0
  48. package/dist/shaders/materials/metal.d.ts.map +1 -0
  49. package/dist/shaders/materials/metal.js +183 -0
  50. package/dist/shaders/materials/nodes.d.ts +2 -0
  51. package/dist/shaders/materials/nodes.d.ts.map +1 -0
  52. package/{src/shaders/nodes.ts → dist/shaders/materials/nodes.js} +32 -16
  53. package/dist/shaders/materials/stockings.d.ts +2 -0
  54. package/dist/shaders/materials/stockings.d.ts.map +1 -0
  55. package/dist/shaders/materials/stockings.js +244 -0
  56. package/dist/shaders/metal.d.ts +1 -1
  57. package/dist/shaders/metal.d.ts.map +1 -1
  58. package/dist/shaders/metal.js +4 -17
  59. package/dist/shaders/nodes.d.ts +1 -1
  60. package/dist/shaders/nodes.d.ts.map +1 -1
  61. package/dist/shaders/nodes.js +0 -9
  62. package/dist/shaders/passes/bloom.d.ts +4 -0
  63. package/dist/shaders/passes/bloom.d.ts.map +1 -0
  64. package/dist/shaders/passes/bloom.js +117 -0
  65. package/dist/shaders/passes/composite.d.ts +2 -0
  66. package/dist/shaders/passes/composite.d.ts.map +1 -0
  67. package/dist/shaders/passes/composite.js +61 -0
  68. package/dist/shaders/passes/ground.d.ts +2 -0
  69. package/dist/shaders/passes/ground.d.ts.map +1 -0
  70. package/dist/shaders/passes/ground.js +93 -0
  71. package/dist/shaders/passes/mipmap.d.ts +2 -0
  72. package/dist/shaders/passes/mipmap.d.ts.map +1 -0
  73. package/dist/shaders/passes/mipmap.js +16 -0
  74. package/dist/shaders/passes/outline.d.ts +2 -0
  75. package/dist/shaders/passes/outline.d.ts.map +1 -0
  76. package/dist/shaders/passes/outline.js +83 -0
  77. package/dist/shaders/passes/pick.d.ts +2 -0
  78. package/dist/shaders/passes/pick.d.ts.map +1 -0
  79. package/dist/shaders/passes/pick.js +39 -0
  80. package/dist/shaders/passes/shadow.d.ts +2 -0
  81. package/dist/shaders/passes/shadow.d.ts.map +1 -0
  82. package/dist/shaders/passes/shadow.js +16 -0
  83. package/dist/shaders/stockings.d.ts +1 -1
  84. package/dist/shaders/stockings.d.ts.map +1 -1
  85. package/package.json +1 -1
  86. package/src/engine.ts +197 -439
  87. package/src/index.ts +3 -2
  88. package/src/shaders/{body.ts → materials/body.ts} +7 -28
  89. package/src/shaders/{cloth_rough.ts → materials/cloth_rough.ts} +4 -16
  90. package/src/shaders/{cloth_smooth.ts → materials/cloth_smooth.ts} +5 -17
  91. package/src/shaders/{face.ts → materials/face.ts} +21 -57
  92. package/src/shaders/{hair.ts → materials/hair.ts} +17 -28
  93. package/src/shaders/{metal.ts → materials/metal.ts} +15 -19
  94. package/src/shaders/materials/nodes.ts +483 -0
  95. package/src/shaders/passes/bloom.ts +121 -0
  96. package/src/shaders/passes/composite.ts +62 -0
  97. package/src/shaders/passes/ground.ts +94 -0
  98. package/src/shaders/passes/mipmap.ts +17 -0
  99. package/src/shaders/passes/outline.ts +84 -0
  100. package/src/shaders/passes/pick.ts +40 -0
  101. package/src/shaders/passes/shadow.ts +17 -0
  102. package/src/shaders/classify.ts +0 -25
  103. /package/src/shaders/{default.ts → materials/default.ts} +0 -0
  104. /package/src/shaders/{eye.ts → materials/eye.ts} +0 -0
  105. /package/src/shaders/{stockings.ts → materials/stockings.ts} +0 -0
package/src/engine.ts CHANGED
@@ -13,18 +13,51 @@ import {
13
13
  normalizeAssetPath,
14
14
  type AssetReader,
15
15
  } from "./asset-reader"
16
- import { DEFAULT_SHADER_WGSL } from "./shaders/default"
16
+ import { DEFAULT_SHADER_WGSL } from "./shaders/materials/default"
17
+ import { FACE_SHADER_WGSL } from "./shaders/materials/face"
18
+ import { HAIR_SHADER_WGSL } from "./shaders/materials/hair"
19
+ import { CLOTH_SMOOTH_SHADER_WGSL } from "./shaders/materials/cloth_smooth"
20
+ import { CLOTH_ROUGH_SHADER_WGSL } from "./shaders/materials/cloth_rough"
21
+ import { METAL_SHADER_WGSL } from "./shaders/materials/metal"
22
+ import { BODY_SHADER_WGSL } from "./shaders/materials/body"
23
+ import { EYE_SHADER_WGSL } from "./shaders/materials/eye"
24
+ import { STOCKINGS_SHADER_WGSL } from "./shaders/materials/stockings"
17
25
  import { BRDF_LUT_SIZE, BRDF_LUT_BAKE_WGSL } from "./shaders/dfg_lut"
18
26
  import { LTC_MAG_LUT_SIZE, LTC_MAG_LUT_DATA } from "./shaders/ltc_mag_lut"
19
- import { FACE_SHADER_WGSL } from "./shaders/face"
20
- import { HAIR_SHADER_WGSL } from "./shaders/hair"
21
- import { CLOTH_SMOOTH_SHADER_WGSL } from "./shaders/cloth_smooth"
22
- import { CLOTH_ROUGH_SHADER_WGSL } from "./shaders/cloth_rough"
23
- import { METAL_SHADER_WGSL } from "./shaders/metal"
24
- import { BODY_SHADER_WGSL } from "./shaders/body"
25
- import { EYE_SHADER_WGSL } from "./shaders/eye"
26
- import { STOCKINGS_SHADER_WGSL } from "./shaders/stockings"
27
- import { resolvePreset, type MaterialPreset, type MaterialPresetMap } from "./shaders/classify"
27
+ import { SHADOW_DEPTH_SHADER_WGSL } from "./shaders/passes/shadow"
28
+ import { GROUND_SHADOW_SHADER_WGSL } from "./shaders/passes/ground"
29
+ import { OUTLINE_SHADER_WGSL } from "./shaders/passes/outline"
30
+ import {
31
+ BLOOM_BLIT_SHADER_WGSL,
32
+ BLOOM_DOWNSAMPLE_SHADER_WGSL,
33
+ BLOOM_UPSAMPLE_SHADER_WGSL,
34
+ } from "./shaders/passes/bloom"
35
+ import { COMPOSITE_SHADER_WGSL } from "./shaders/passes/composite"
36
+ import { PICK_SHADER_WGSL } from "./shaders/passes/pick"
37
+ import { MIPMAP_BLIT_SHADER_WGSL } from "./shaders/passes/mipmap"
38
+
39
+ // Material preset dispatch. Consumers supply a MaterialPresetMap assigning material names
40
+ // to presets; unmapped materials fall back to "default" (Principled BSDF).
41
+ export type MaterialPreset =
42
+ | "default"
43
+ | "face"
44
+ | "hair"
45
+ | "body"
46
+ | "eye"
47
+ | "stockings"
48
+ | "metal"
49
+ | "cloth_smooth"
50
+ | "cloth_rough"
51
+
52
+ export type MaterialPresetMap = Partial<Record<MaterialPreset, string[]>>
53
+
54
+ function resolvePreset(materialName: string, map: MaterialPresetMap | undefined): MaterialPreset {
55
+ if (!map) return "default"
56
+ for (const [preset, names] of Object.entries(map)) {
57
+ if (names && names.includes(materialName)) return preset as MaterialPreset
58
+ }
59
+ return "default"
60
+ }
28
61
 
29
62
  export type RaycastCallback = (modelName: string, material: string | null, screenX: number, screenY: number) => void
30
63
 
@@ -199,6 +232,7 @@ export class Engine {
199
232
  private metalPipeline!: GPURenderPipeline
200
233
  private bodyPipeline!: GPURenderPipeline
201
234
  private eyePipeline!: GPURenderPipeline
235
+ private hairOverEyesPipeline!: GPURenderPipeline
202
236
  private stockingsPipeline!: GPURenderPipeline
203
237
  private groundShadowPipeline!: GPURenderPipeline
204
238
  private groundShadowBindGroupLayout!: GPUBindGroupLayout
@@ -214,6 +248,9 @@ export class Engine {
214
248
  private hdrResolveTexture!: GPUTexture
215
249
  private static readonly MULTISAMPLE_COUNT = 4
216
250
  private static readonly HDR_FORMAT: GPUTextureFormat = "rgba16float"
251
+ /** Stencil value stamped by eye draws so hair can stencil-test against it and
252
+ * alpha-blend a second pass over eye silhouette pixels (see-through-hair effect). */
253
+ private static readonly STENCIL_EYE_VALUE = 1
217
254
  /** Single-channel mask written alongside HDR color — 1 = model geometry (contributes
218
255
  * to bloom), 0 = ground (never blooms). Sampled by the bloom blit pass to gate the
219
256
  * prefilter so ground brightness can't halo the scene. */
@@ -223,11 +260,15 @@ export class Engine {
223
260
  private maskResolveView!: GPUTextureView
224
261
  private renderPassDescriptor!: GPURenderPassDescriptor
225
262
  private compositePassDescriptor!: GPURenderPassDescriptor
226
- private compositePipeline!: GPURenderPipeline
263
+ // Two specialized composite pipelines via WGSL pipeline-override constants.
264
+ // Identity variant skips the gamma pow entirely at shader-compile time —
265
+ // Safari's Metal backend won't fold pow(x, 1) to identity.
266
+ private compositePipelineIdentity!: GPURenderPipeline
267
+ private compositePipelineGamma!: GPURenderPipeline
227
268
  private compositeBindGroupLayout!: GPUBindGroupLayout
228
269
  private compositeBindGroup!: GPUBindGroup
229
270
  private compositeUniformBuffer!: GPUBuffer
230
- // [exposure, gamma, _, _, bloomTint.x, bloomTint.y, bloomTint.z, bloomIntensity]
271
+ // [exposure, invGamma, _, _, bloomTint.x, bloomTint.y, bloomTint.z, bloomIntensity]
231
272
  private readonly compositeUniformData = new Float32Array(8)
232
273
 
233
274
  // EEVEE-style bloom pyramid (mirrors Blender 3.6 effect_bloom_frag.glsl):
@@ -403,7 +444,10 @@ export class Engine {
403
444
  const effIntensity = b.enabled ? b.intensity : 0.0
404
445
  const u = this.compositeUniformData
405
446
  u[0] = v.exposure
406
- u[1] = Math.max(v.gamma, 1e-4)
447
+ // Store 1/gamma so the shader avoids a per-pixel divide. Safari's Metal
448
+ // compiler doesn't fold `pow(x, 1/g)` into identity when g=1, so also emit
449
+ // a uniform branch that skips the pow entirely in the common case.
450
+ u[1] = 1.0 / Math.max(v.gamma, 1e-4)
407
451
  u[2] = 0.0
408
452
  u[3] = 0.0
409
453
  u[4] = b.color.x
@@ -782,6 +826,9 @@ export class Engine {
782
826
  },
783
827
  })
784
828
 
829
+ // Hair opaque: stencil != EYE_VALUE so fragments on top of eyes are skipped entirely —
830
+ // depth and color stay as the eye wrote them; the follow-up hairOverEyesPipeline then
831
+ // draws those skipped fragments alpha-blended so the eye reads through the hair.
785
832
  this.hairPipeline = this.createRenderPipeline({
786
833
  label: "hair NPR pipeline",
787
834
  layout: mainPipelineLayout,
@@ -793,7 +840,37 @@ export class Engine {
793
840
  format: "depth24plus-stencil8",
794
841
  depthWriteEnabled: true,
795
842
  depthCompare: "less-equal",
843
+ stencilFront: { compare: "not-equal", failOp: "keep", depthFailOp: "keep", passOp: "keep" },
844
+ stencilBack: { compare: "not-equal", failOp: "keep", depthFailOp: "keep", passOp: "keep" },
845
+ stencilReadMask: 0xff,
846
+ stencilWriteMask: 0,
847
+ },
848
+ })
849
+
850
+ // Hair-over-eyes: same shader with IS_OVER_EYES=true so alpha is halved at compile time.
851
+ // Only fragments where eye stencil == EYE_VALUE pass; depth test still culls fragments
852
+ // that are further from camera than the eye, so hair behind the eye never shows through.
853
+ // depthWriteEnabled=false keeps the eye's depth authoritative for everything drawn after.
854
+ this.hairOverEyesPipeline = this.device.createRenderPipeline({
855
+ label: "hair over eyes pipeline",
856
+ layout: mainPipelineLayout,
857
+ vertex: { module: hairShaderModule, buffers: fullVertexBuffers },
858
+ fragment: {
859
+ module: hairShaderModule,
860
+ constants: { IS_OVER_EYES: 1 },
861
+ targets: sceneTargets,
862
+ },
863
+ primitive: { cullMode: "none" },
864
+ depthStencil: {
865
+ format: "depth24plus-stencil8",
866
+ depthWriteEnabled: false,
867
+ depthCompare: "less-equal",
868
+ stencilFront: { compare: "equal", failOp: "keep", depthFailOp: "keep", passOp: "keep" },
869
+ stencilBack: { compare: "equal", failOp: "keep", depthFailOp: "keep", passOp: "keep" },
870
+ stencilReadMask: 0xff,
871
+ stencilWriteMask: 0,
796
872
  },
873
+ multisample: { count: Engine.MULTISAMPLE_COUNT },
797
874
  })
798
875
 
799
876
  this.clothSmoothPipeline = this.createRenderPipeline({
@@ -852,17 +929,30 @@ export class Engine {
852
929
  },
853
930
  })
854
931
 
932
+ // Eye: stamps stencil = EYE_VALUE on every fragment it writes. Later hair passes read
933
+ // this stamp to split into "draw normally (not over eye)" vs "draw alpha-blended".
934
+ // cullMode="front" + small negative depthBias is the MMD post-alpha-eye trick: only the
935
+ // back half of the eye sphere renders, it passes depth against the face (via bias) when
936
+ // viewed from the front, and it gets culled when viewed from behind — so eye fragments
937
+ // can't leak through the back of the head without needing a per-model skull occluder.
855
938
  this.eyePipeline = this.createRenderPipeline({
856
939
  label: "eye pipeline",
857
940
  layout: mainPipelineLayout,
858
941
  shaderModule: eyeShaderModule,
859
942
  vertexBuffers: fullVertexBuffers,
860
943
  fragmentTargets: sceneTargets,
861
- cullMode: "none",
944
+ cullMode: "front",
862
945
  depthStencil: {
863
946
  format: "depth24plus-stencil8",
864
947
  depthWriteEnabled: true,
865
948
  depthCompare: "less-equal",
949
+ depthBias: -0.00005,
950
+ depthBiasSlopeScale: 0.0,
951
+ depthBiasClamp: 0.0,
952
+ stencilFront: { compare: "always", failOp: "keep", depthFailOp: "keep", passOp: "replace" },
953
+ stencilBack: { compare: "always", failOp: "keep", depthFailOp: "keep", passOp: "replace" },
954
+ stencilReadMask: 0xff,
955
+ stencilWriteMask: 0xff,
866
956
  },
867
957
  })
868
958
 
@@ -893,21 +983,7 @@ export class Engine {
893
983
  })
894
984
  const shadowShader = this.device.createShaderModule({
895
985
  label: "shadow depth",
896
- code: /* wgsl */ `
897
- struct LightVP { viewProj: mat4x4f, };
898
- @group(0) @binding(0) var<uniform> lp: LightVP;
899
- @group(0) @binding(1) var<storage, read> skinMats: array<mat4x4f>;
900
- @vertex fn vs(@location(0) position: vec3f, @location(1) normal: vec3f, @location(2) uv: vec2f,
901
- @location(3) joints0: vec4<u32>, @location(4) weights0: vec4<f32>) -> @builtin(position) vec4f {
902
- let pos4 = vec4f(position, 1.0);
903
- let ws = weights0.x + weights0.y + weights0.z + weights0.w;
904
- let inv = select(1.0, 1.0 / ws, ws > 0.0001);
905
- let nw = select(vec4f(1.0,0.0,0.0,0.0), weights0 * inv, ws > 0.0001);
906
- var sp = vec4f(0.0);
907
- for (var i = 0u; i < 4u; i++) { sp += (skinMats[joints0[i]] * pos4) * nw[i]; }
908
- return lp.viewProj * vec4f(sp.xyz, 1.0);
909
- }
910
- `,
986
+ code: SHADOW_DEPTH_SHADER_WGSL,
911
987
  })
912
988
  this.shadowDepthPipeline = this.device.createRenderPipeline({
913
989
  label: "shadow depth pipeline",
@@ -967,99 +1043,7 @@ export class Engine {
967
1043
  })
968
1044
  const groundShadowShader = this.device.createShaderModule({
969
1045
  label: "ground shadow",
970
- code: /* wgsl */ `
971
- struct CameraUniforms { view: mat4x4f, projection: mat4x4f, viewPos: vec3f, _p: f32, };
972
- struct Light { direction: vec4f, color: vec4f, };
973
- struct LightUniforms { ambientColor: vec4f, lights: array<Light, 4>, };
974
- struct GroundShadowMat {
975
- diffuseColor: vec3f, fadeStart: f32,
976
- fadeEnd: f32, shadowStrength: f32, pcfTexel: f32, gridSpacing: f32,
977
- gridLineWidth: f32, gridLineOpacity: f32, noiseStrength: f32, _pad: f32,
978
- gridLineColor: vec3f, _pad2: f32,
979
- };
980
- struct LightVP { viewProj: mat4x4f, };
981
- @group(0) @binding(0) var<uniform> camera: CameraUniforms;
982
- @group(0) @binding(1) var<uniform> light: LightUniforms;
983
- @group(0) @binding(2) var shadowMap: texture_depth_2d;
984
- @group(0) @binding(3) var shadowSampler: sampler_comparison;
985
- @group(0) @binding(4) var<uniform> material: GroundShadowMat;
986
- @group(0) @binding(5) var<uniform> lightVP: LightVP;
987
-
988
- // Hash-based noise for frosted/matte surface
989
- fn hash2(p: vec2f) -> f32 {
990
- var p3 = fract(vec3f(p.x, p.y, p.x) * 0.1031);
991
- p3 += dot(p3, vec3f(p3.y + 33.33, p3.z + 33.33, p3.x + 33.33));
992
- return fract((p3.x + p3.y) * p3.z);
993
- }
994
- fn valueNoise(p: vec2f) -> f32 {
995
- let i = floor(p);
996
- let f = fract(p);
997
- let u = f * f * (3.0 - 2.0 * f);
998
- return mix(mix(hash2(i), hash2(i + vec2f(1.0, 0.0)), u.x),
999
- mix(hash2(i + vec2f(0.0, 1.0)), hash2(i + vec2f(1.0, 1.0)), u.x), u.y);
1000
- }
1001
- fn fbmNoise(p: vec2f) -> f32 {
1002
- var v = 0.0;
1003
- var a = 0.5;
1004
- var pp = p;
1005
- for (var i = 0; i < 4; i++) {
1006
- v += a * valueNoise(pp);
1007
- pp *= 2.0;
1008
- a *= 0.5;
1009
- }
1010
- return v;
1011
- }
1012
-
1013
- struct VO { @builtin(position) position: vec4f, @location(0) worldPos: vec3f, @location(1) normal: vec3f, };
1014
- @vertex fn vs(@location(0) position: vec3f, @location(1) normal: vec3f, @location(2) uv: vec2f) -> VO {
1015
- var o: VO; o.worldPos = position; o.normal = normal;
1016
- o.position = camera.projection * camera.view * vec4f(position, 1.0); return o;
1017
- }
1018
- struct FSOut { @location(0) color: vec4f, @location(1) mask: f32 };
1019
- @fragment fn fs(i: VO) -> FSOut {
1020
- let n = normalize(i.normal);
1021
- let centerDist = length(i.worldPos.xz);
1022
- let edgeFade = 1.0 - smoothstep(0.0, 1.0, clamp((centerDist - material.fadeStart) / max(material.fadeEnd - material.fadeStart, 0.001), 0.0, 1.0));
1023
-
1024
- // Shadow sampling
1025
- let lclip = lightVP.viewProj * vec4f(i.worldPos, 1.0);
1026
- let ndc = lclip.xyz / max(lclip.w, 1e-6);
1027
- let suv = vec2f(ndc.x * 0.5 + 0.5, 0.5 - ndc.y * 0.5);
1028
- let suv_c = clamp(suv, vec2f(0.02), vec2f(0.98));
1029
- let st = material.pcfTexel;
1030
- let compareZ = ndc.z - 0.0035;
1031
- var vis = 0.0;
1032
- for (var y = -2; y <= 2; y++) {
1033
- for (var x = -2; x <= 2; x++) {
1034
- vis += textureSampleCompare(shadowMap, shadowSampler, suv_c + vec2f(f32(x), f32(y)) * st, compareZ);
1035
- }
1036
- }
1037
- vis *= 0.04;
1038
-
1039
- // Frosted/matte micro-texture (磨砂)
1040
- let noiseVal = fbmNoise(i.worldPos.xz * 3.0);
1041
- let noiseTint = 1.0 + (noiseVal - 0.5) * material.noiseStrength;
1042
-
1043
- // Grid lines — anti-aliased via screen-space derivatives
1044
- let gp = i.worldPos.xz / material.gridSpacing;
1045
- let gridFrac = abs(fract(gp - 0.5) - 0.5);
1046
- let gridDeriv = fwidth(gp);
1047
- let halfLine = material.gridLineWidth * 0.5;
1048
- let gridLine = 1.0 - min(
1049
- smoothstep(halfLine - gridDeriv.x, halfLine + gridDeriv.x, gridFrac.x),
1050
- smoothstep(halfLine - gridDeriv.y, halfLine + gridDeriv.y, gridFrac.y)
1051
- );
1052
- let sun = light.ambientColor.xyz + light.lights[0].color.xyz * light.lights[0].color.w * max(dot(n, -light.lights[0].direction.xyz), 0.0);
1053
- let dark = (1.0 - vis) * material.shadowStrength;
1054
- var baseColor = material.diffuseColor * sun * (1.0 - dark * 0.65);
1055
- baseColor *= noiseTint;
1056
- let finalColor = mix(baseColor, material.gridLineColor, gridLine * material.gridLineOpacity * edgeFade);
1057
- var out: FSOut;
1058
- out.color = vec4f(finalColor * edgeFade, edgeFade);
1059
- out.mask = 0.0;
1060
- return out;
1061
- }
1062
- `,
1046
+ code: GROUND_SHADOW_SHADER_WGSL,
1063
1047
  })
1064
1048
  this.groundShadowPipeline = this.createRenderPipeline({
1065
1049
  label: "ground shadow pipeline",
@@ -1103,90 +1087,7 @@ export class Engine {
1103
1087
 
1104
1088
  const outlineShaderModule = this.device.createShaderModule({
1105
1089
  label: "outline shaders",
1106
- code: /* wgsl */ `
1107
- struct CameraUniforms {
1108
- view: mat4x4f,
1109
- projection: mat4x4f,
1110
- viewPos: vec3f,
1111
- _padding: f32,
1112
- };
1113
-
1114
- struct MaterialUniforms {
1115
- edgeColor: vec4f,
1116
- edgeSize: f32,
1117
- _padding1: f32,
1118
- _padding2: f32,
1119
- _padding3: f32,
1120
- };
1121
-
1122
- // group 0: per-frame
1123
- @group(0) @binding(0) var<uniform> camera: CameraUniforms;
1124
- // group 1: per-instance
1125
- @group(1) @binding(0) var<storage, read> skinMats: array<mat4x4f>;
1126
- // group 2: per-material
1127
- @group(2) @binding(0) var<uniform> material: MaterialUniforms;
1128
-
1129
- struct VertexOutput {
1130
- @builtin(position) position: vec4f,
1131
- };
1132
-
1133
- @vertex fn vs(
1134
- @location(0) position: vec3f,
1135
- @location(1) normal: vec3f,
1136
- @location(3) joints0: vec4<u32>,
1137
- @location(4) weights0: vec4<f32>
1138
- ) -> VertexOutput {
1139
- var output: VertexOutput;
1140
- let pos4 = vec4f(position, 1.0);
1141
-
1142
- let weightSum = weights0.x + weights0.y + weights0.z + weights0.w;
1143
- let invWeightSum = select(1.0, 1.0 / weightSum, weightSum > 0.0001);
1144
- let normalizedWeights = select(vec4f(1.0, 0.0, 0.0, 0.0), weights0 * invWeightSum, weightSum > 0.0001);
1145
-
1146
- var skinnedPos = vec4f(0.0, 0.0, 0.0, 0.0);
1147
- var skinnedNrm = vec3f(0.0, 0.0, 0.0);
1148
- for (var i = 0u; i < 4u; i++) {
1149
- let j = joints0[i];
1150
- let w = normalizedWeights[i];
1151
- let m = skinMats[j];
1152
- skinnedPos += (m * pos4) * w;
1153
- let r3 = mat3x3f(m[0].xyz, m[1].xyz, m[2].xyz);
1154
- skinnedNrm += (r3 * normal) * w;
1155
- }
1156
- let worldPos = skinnedPos.xyz;
1157
- let worldNormal = normalize(skinnedNrm);
1158
-
1159
- // Screen-space outline extrusion — MMD-style pixel-stable edge line.
1160
- // 1. Project position and normal-as-direction to clip space.
1161
- // 2. Normalize the 2D clip-space normal, aspect-compensated so "one pixel horizontally"
1162
- // matches "one pixel vertically" (otherwise wide viewports squash the outline in X).
1163
- // 3. Offset clip-space xy by (normal * edgeSize * edgeScale), then multiply by w
1164
- // so the perspective divide cancels out → offset stays constant in NDC regardless
1165
- // of depth, matching how MMD / babylon-mmd style outlines look identical when zooming.
1166
- // 4. edgeScale is in NDC-y units per PMX edgeSize. ≈ 0.006 gives ~3px at 1080p; it's
1167
- // tied to viewport HEIGHT so resizing the window keeps pixel thickness stable.
1168
- let viewProj = camera.projection * camera.view;
1169
- let clipPos = viewProj * vec4f(worldPos, 1.0);
1170
- let clipNormal = (viewProj * vec4f(worldNormal, 0.0)).xy;
1171
- // projection is column-major: proj[0][0] = 1/(aspect·tan(fov/2)), proj[1][1] = 1/tan(fov/2).
1172
- // Ratio proj[1][1]/proj[0][0] recovers the viewport aspect (width/height).
1173
- let aspect = camera.projection[1][1] / camera.projection[0][0];
1174
- let pixelDir = normalize(vec2f(clipNormal.x * aspect, clipNormal.y));
1175
- let ndcDir = vec2f(pixelDir.x / aspect, pixelDir.y);
1176
- let edgeScale = 0.0016;
1177
- let offset = ndcDir * material.edgeSize * edgeScale * clipPos.w;
1178
- output.position = vec4f(clipPos.xy + offset, clipPos.z, clipPos.w);
1179
- return output;
1180
- }
1181
-
1182
- struct FSOut { @location(0) color: vec4f, @location(1) mask: f32 };
1183
- @fragment fn fs() -> FSOut {
1184
- var out: FSOut;
1185
- out.color = material.edgeColor;
1186
- out.mask = 1.0;
1187
- return out;
1188
- }
1189
- `,
1090
+ code: OUTLINE_SHADER_WGSL,
1190
1091
  })
1191
1092
 
1192
1093
  this.outlinePipeline = this.createRenderPipeline({
@@ -1201,6 +1102,13 @@ export class Engine {
1201
1102
  // Don’t write outline into depth buffer — stops z-fighting / black cracks vs body (MMD-style; body depth stays authoritative)
1202
1103
  depthWriteEnabled: false,
1203
1104
  depthCompare: "less-equal",
1105
+ // Skip fragments where the eye stamped stencil=EYE_VALUE. Those pixels are owned by
1106
+ // the see-through-hair blend (hair-over-eyes), so letting the outline's near-black
1107
+ // edge color overwrite them would re-introduce the dark almond we just killed.
1108
+ stencilFront: { compare: "not-equal", failOp: "keep", depthFailOp: "keep", passOp: "keep" },
1109
+ stencilBack: { compare: "not-equal", failOp: "keep", depthFailOp: "keep", passOp: "keep" },
1110
+ stencilReadMask: 0xff,
1111
+ stencilWriteMask: 0,
1204
1112
  },
1205
1113
  })
1206
1114
 
@@ -1251,130 +1159,19 @@ export class Engine {
1251
1159
  ],
1252
1160
  })
1253
1161
 
1254
- const bloomFullscreenVs = /* wgsl */ `
1255
- @vertex fn vs(@builtin(vertex_index) vi: u32) -> @builtin(position) vec4f {
1256
- let x = f32((vi & 1u) << 2u) - 1.0;
1257
- let y = f32((vi & 2u) << 1u) - 1.0;
1258
- return vec4f(x, y, 0.0, 1.0);
1259
- }
1260
- `
1261
-
1262
- // Blit: full-res HDR → half-res. Karis 4-tap firefly average + EEVEE quadratic knee threshold + clamp.
1263
1162
  const bloomBlitShader = this.device.createShaderModule({
1264
1163
  label: "bloom blit (Karis prefilter)",
1265
- code: `${bloomFullscreenVs}
1266
- @group(0) @binding(0) var hdrTex: texture_2d<f32>;
1267
- @group(0) @binding(1) var<uniform> prefilter: vec4<f32>; // threshold, knee, clamp, _unused
1268
- @group(0) @binding(2) var maskTex: texture_2d<f32>;
1269
-
1270
- fn luminance(c: vec3f) -> f32 {
1271
- return dot(max(c, vec3f(0.0)), vec3f(0.2126, 0.7152, 0.0722));
1272
- }
1273
- fn fetch(c: vec2<i32>, clampV: f32) -> vec3f {
1274
- let d = vec2<i32>(textureDimensions(hdrTex));
1275
- let cc = clamp(c, vec2<i32>(0), d - vec2<i32>(1));
1276
- let s = textureLoad(hdrTex, cc, 0);
1277
- // Scene pass uses src-alpha blend with clear alpha 0 → premultiplied. Unpremultiply.
1278
- let rgb = max(s.rgb / max(s.a, 1e-6), vec3f(0.0));
1279
- // Bloom mask: MRT r8unorm written by material shaders (1.0 = bloom, 0.0 = skip).
1280
- let mask = textureLoad(maskTex, cc, 0).r;
1281
- let masked = rgb * mask;
1282
- // Blender: clamp each tap BEFORE Karis average (eevee_bloom: color = min(clampIntensity, color)).
1283
- return select(masked, min(masked, vec3f(clampV)), clampV > 0.0);
1284
- }
1285
-
1286
- @fragment fn fs(@builtin(position) p: vec4f) -> @location(0) vec4f {
1287
- let dst = vec2<i32>(p.xy - vec2f(0.5));
1288
- let base = dst * 2;
1289
- let clampV = prefilter.z;
1290
- let a = fetch(base + vec2<i32>(0, 0), clampV);
1291
- let b = fetch(base + vec2<i32>(1, 0), clampV);
1292
- let c = fetch(base + vec2<i32>(0, 1), clampV);
1293
- let d = fetch(base + vec2<i32>(1, 1), clampV);
1294
- // Karis partial average: weight each tap by 1/(1+luma) — suppresses fireflies.
1295
- let wa = 1.0 / (1.0 + luminance(a));
1296
- let wb = 1.0 / (1.0 + luminance(b));
1297
- let wc = 1.0 / (1.0 + luminance(c));
1298
- let wd = 1.0 / (1.0 + luminance(d));
1299
- let avg = (a * wa + b * wb + c * wc + d * wd) / max(wa + wb + wc + wd, 1e-6);
1300
- // EEVEE quadratic threshold (brightness = max-channel, then soft-knee curve).
1301
- let bright = max(avg.r, max(avg.g, avg.b));
1302
- let soft = clamp(bright - prefilter.x + prefilter.y, 0.0, 2.0 * prefilter.y);
1303
- let q = (soft * soft) / (4.0 * max(prefilter.y, 1e-4) + 1e-6);
1304
- let contrib = max(q, bright - prefilter.x) / max(bright, 1e-4);
1305
- return vec4f(max(avg * contrib, vec3f(0.0)), 1.0);
1306
- }
1307
- `,
1164
+ code: BLOOM_BLIT_SHADER_WGSL,
1308
1165
  })
1309
1166
 
1310
- // Downsample: Jimenez/COD 13-tap dual-box — 5 weighted 2×2 averages, rejects nyquist ringing.
1311
1167
  const bloomDownsampleShader = this.device.createShaderModule({
1312
1168
  label: "bloom downsample 13-tap",
1313
- code: `${bloomFullscreenVs}
1314
- @group(0) @binding(0) var srcTex: texture_2d<f32>;
1315
- @group(0) @binding(1) var srcSamp: sampler;
1316
-
1317
- fn samp(uv: vec2f, off: vec2f) -> vec3f {
1318
- return textureSampleLevel(srcTex, srcSamp, uv + off, 0.0).rgb;
1319
- }
1320
-
1321
- @fragment fn fs(@builtin(position) p: vec4f) -> @location(0) vec4f {
1322
- let srcDims = vec2f(textureDimensions(srcTex));
1323
- let t = 1.0 / srcDims;
1324
- // fragCoord.xy reports pixel centers (e.g. 0.5,0.5 for first pixel) — divide by dst dims directly.
1325
- let dstDims = srcDims * 0.5;
1326
- let uv = p.xy / max(dstDims, vec2f(1.0));
1327
- let A = samp(uv, t * vec2f(-2.0, -2.0));
1328
- let B = samp(uv, t * vec2f( 0.0, -2.0));
1329
- let C = samp(uv, t * vec2f( 2.0, -2.0));
1330
- let D = samp(uv, t * vec2f(-1.0, -1.0));
1331
- let E = samp(uv, t * vec2f( 1.0, -1.0));
1332
- let F = samp(uv, t * vec2f(-2.0, 0.0));
1333
- let G = samp(uv, t * vec2f( 0.0, 0.0));
1334
- let H = samp(uv, t * vec2f( 2.0, 0.0));
1335
- let I = samp(uv, t * vec2f(-1.0, 1.0));
1336
- let J = samp(uv, t * vec2f( 1.0, 1.0));
1337
- let K = samp(uv, t * vec2f(-2.0, 2.0));
1338
- let L = samp(uv, t * vec2f( 0.0, 2.0));
1339
- let M = samp(uv, t * vec2f( 2.0, 2.0));
1340
- var o = (D + E + I + J) * (0.5 / 4.0);
1341
- o = o + (A + B + G + F) * (0.125 / 4.0);
1342
- o = o + (B + C + H + G) * (0.125 / 4.0);
1343
- o = o + (F + G + L + K) * (0.125 / 4.0);
1344
- o = o + (G + H + M + L) * (0.125 / 4.0);
1345
- return vec4f(o, 1.0);
1346
- }
1347
- `,
1169
+ code: BLOOM_DOWNSAMPLE_SHADER_WGSL,
1348
1170
  })
1349
1171
 
1350
- // Upsample: 9-tap tent, progressively added to matching downsample mip. Blender radius = sample scale.
1351
1172
  const bloomUpsampleShader = this.device.createShaderModule({
1352
1173
  label: "bloom upsample 9-tap tent",
1353
- code: `${bloomFullscreenVs}
1354
- @group(0) @binding(0) var srcTex: texture_2d<f32>; // coarser accumulator
1355
- @group(0) @binding(1) var baseTex: texture_2d<f32>; // matching downsample mip
1356
- @group(0) @binding(2) var srcSamp: sampler;
1357
- @group(0) @binding(3) var<uniform> upU: vec4<f32>; // sampleScale, _, _, _
1358
-
1359
- @fragment fn fs(@builtin(position) p: vec4f) -> @location(0) vec4f {
1360
- let srcDims = vec2f(textureDimensions(srcTex));
1361
- let baseDims = vec2f(textureDimensions(baseTex));
1362
- let uv = p.xy / max(baseDims, vec2f(1.0));
1363
- let t = upU.x / srcDims;
1364
- var o = textureSampleLevel(srcTex, srcSamp, uv + t * vec2f(-1.0, -1.0), 0.0).rgb * 1.0;
1365
- o = o + textureSampleLevel(srcTex, srcSamp, uv + t * vec2f( 0.0, -1.0), 0.0).rgb * 2.0;
1366
- o = o + textureSampleLevel(srcTex, srcSamp, uv + t * vec2f( 1.0, -1.0), 0.0).rgb * 1.0;
1367
- o = o + textureSampleLevel(srcTex, srcSamp, uv + t * vec2f(-1.0, 0.0), 0.0).rgb * 2.0;
1368
- o = o + textureSampleLevel(srcTex, srcSamp, uv + t * vec2f( 0.0, 0.0), 0.0).rgb * 4.0;
1369
- o = o + textureSampleLevel(srcTex, srcSamp, uv + t * vec2f( 1.0, 0.0), 0.0).rgb * 2.0;
1370
- o = o + textureSampleLevel(srcTex, srcSamp, uv + t * vec2f(-1.0, 1.0), 0.0).rgb * 1.0;
1371
- o = o + textureSampleLevel(srcTex, srcSamp, uv + t * vec2f( 0.0, 1.0), 0.0).rgb * 2.0;
1372
- o = o + textureSampleLevel(srcTex, srcSamp, uv + t * vec2f( 1.0, 1.0), 0.0).rgb * 1.0;
1373
- o = o * (1.0 / 16.0);
1374
- let base = textureSampleLevel(baseTex, srcSamp, uv, 0.0).rgb;
1375
- return vec4f(o + base, 1.0);
1376
- }
1377
- `,
1174
+ code: BLOOM_UPSAMPLE_SHADER_WGSL,
1378
1175
  })
1379
1176
 
1380
1177
  const bloomBlitLayout = this.device.createPipelineLayout({ bindGroupLayouts: [this.bloomBlitBindGroupLayout] })
@@ -1425,68 +1222,27 @@ export class Engine {
1425
1222
 
1426
1223
  const compositeShader = this.device.createShaderModule({
1427
1224
  label: "composite shader",
1428
- code: /* wgsl */ `
1429
- @group(0) @binding(0) var hdrTex: texture_2d<f32>;
1430
- @group(0) @binding(1) var bloomTex: texture_2d<f32>; // bloomUpTexture mip 0 (full pyramid top)
1431
- @group(0) @binding(2) var bloomSamp: sampler;
1432
- @group(0) @binding(3) var<uniform> viewU: array<vec4<f32>, 2>;
1433
- // viewU[0] = (exposure, gamma, _, _); viewU[1] = (tint.rgb, intensity)
1434
-
1435
- fn filmic(x: f32) -> f32 {
1436
- // Re-fit against Blender 3.6 Filmic MHC anchors (sobotka/filmic-blender
1437
- // look_medium-high-contrast.spi1d). Previous curve was compressed:
1438
- // midtones too bright, highlights too dim — flattened contrast, read
1439
- // as "washed-out" on saturated surfaces (hair especially).
1440
- // Reference checkpoints: linear 0.18 → ~0.395, linear 1.0 → ~0.83.
1441
- var lut = array<f32, 14>(
1442
- 0.0028, 0.0068, 0.0151, 0.0313, 0.0610, 0.1120, 0.1920,
1443
- 0.3060, 0.4590, 0.6310, 0.8200, 0.9070, 0.9620, 0.9890
1444
- );
1445
- let t = clamp(log2(max(x, 1e-10)) + 10.0, 0.0, 13.0);
1446
- let i = u32(t);
1447
- let j = min(i + 1u, 13u);
1448
- return mix(lut[i], lut[j], t - f32(i));
1449
- }
1450
-
1451
- @vertex fn vs(@builtin(vertex_index) vi: u32) -> @builtin(position) vec4f {
1452
- let x = f32((vi & 1u) << 2u) - 1.0;
1453
- let y = f32((vi & 2u) << 1u) - 1.0;
1454
- return vec4f(x, y, 0.0, 1.0);
1455
- }
1456
-
1457
- @fragment fn fs(@builtin(position) fragCoord: vec4f) -> @location(0) vec4f {
1458
- let hdr = textureLoad(hdrTex, vec2<i32>(fragCoord.xy), 0);
1459
- let a = max(hdr.a, 1e-6);
1460
- let straight = hdr.rgb / a;
1461
- let fullSz = vec2f(textureDimensions(hdrTex));
1462
- let bloomSz = vec2f(textureDimensions(bloomTex));
1463
- // Bloom is at half-res (pyramid mip 0). Sampler interpolates back to full-res UVs.
1464
- // fragCoord.xy is already at pixel center (e.g. 0.5, 0.5 for first pixel).
1465
- let bloomUv = fragCoord.xy / max(fullSz, vec2f(1.0));
1466
- let tint = viewU[1].xyz;
1467
- let intensity = viewU[1].w;
1468
- let bloom = textureSampleLevel(bloomTex, bloomSamp, bloomUv, 0.0).rgb * tint * intensity;
1469
- let combined = straight + bloom;
1470
- let exposed = combined * exp2(viewU[0].x);
1471
- let tm = vec3f(filmic(exposed.r), filmic(exposed.g), filmic(exposed.b));
1472
- let g = max(viewU[0].y, 1e-4);
1473
- let disp = pow(max(tm, vec3f(0.0)), vec3f(1.0 / g));
1474
- return vec4f(disp * hdr.a, hdr.a);
1475
- }
1476
- `,
1477
- })
1478
-
1479
- this.compositePipeline = this.device.createRenderPipeline({
1480
- label: "composite pipeline",
1481
- layout: this.device.createPipelineLayout({ bindGroupLayouts: [this.compositeBindGroupLayout] }),
1482
- vertex: { module: compositeShader, entryPoint: "vs" },
1483
- fragment: {
1484
- module: compositeShader,
1485
- entryPoint: "fs",
1486
- targets: [{ format: this.presentationFormat }],
1487
- },
1488
- primitive: { topology: "triangle-list" },
1489
- })
1225
+ code: COMPOSITE_SHADER_WGSL,
1226
+ })
1227
+
1228
+ const compositePipelineLayout = this.device.createPipelineLayout({
1229
+ bindGroupLayouts: [this.compositeBindGroupLayout],
1230
+ })
1231
+ const makeCompositePipeline = (applyGamma: boolean, label: string): GPURenderPipeline =>
1232
+ this.device.createRenderPipeline({
1233
+ label,
1234
+ layout: compositePipelineLayout,
1235
+ vertex: { module: compositeShader, entryPoint: "vs" },
1236
+ fragment: {
1237
+ module: compositeShader,
1238
+ entryPoint: "fs",
1239
+ constants: { APPLY_GAMMA: applyGamma ? 1 : 0 },
1240
+ targets: [{ format: this.presentationFormat }],
1241
+ },
1242
+ primitive: { topology: "triangle-list" },
1243
+ })
1244
+ this.compositePipelineIdentity = makeCompositePipeline(false, "composite pipeline (gamma=1)")
1245
+ this.compositePipelineGamma = makeCompositePipeline(true, "composite pipeline (gamma!=1)")
1490
1246
 
1491
1247
  this.bloomPassDescriptor = {
1492
1248
  label: "bloom pass",
@@ -1500,47 +1256,9 @@ export class Engine {
1500
1256
  ],
1501
1257
  } as GPURenderPassDescriptor
1502
1258
 
1503
- // GPU picking: encode (modelIndex, materialIndex) as color
1504
1259
  const pickShaderModule = this.device.createShaderModule({
1505
1260
  label: "pick shader",
1506
- code: /* wgsl */ `
1507
- struct CameraUniforms {
1508
- view: mat4x4f,
1509
- projection: mat4x4f,
1510
- viewPos: vec3f,
1511
- _padding: f32,
1512
- };
1513
- struct PickId {
1514
- modelId: f32,
1515
- materialId: f32,
1516
- _p1: f32,
1517
- _p2: f32,
1518
- };
1519
-
1520
- @group(0) @binding(0) var<uniform> camera: CameraUniforms;
1521
- @group(1) @binding(0) var<storage, read> skinMats: array<mat4x4f>;
1522
- @group(2) @binding(0) var<uniform> pickId: PickId;
1523
-
1524
- @vertex fn vs(
1525
- @location(0) position: vec3f,
1526
- @location(1) normal: vec3f,
1527
- @location(2) uv: vec2f,
1528
- @location(3) joints0: vec4<u32>,
1529
- @location(4) weights0: vec4<f32>
1530
- ) -> @builtin(position) vec4f {
1531
- let pos4 = vec4f(position, 1.0);
1532
- let weightSum = weights0.x + weights0.y + weights0.z + weights0.w;
1533
- let invWeightSum = select(1.0, 1.0 / weightSum, weightSum > 0.0001);
1534
- let nw = select(vec4f(1.0, 0.0, 0.0, 0.0), weights0 * invWeightSum, weightSum > 0.0001);
1535
- var sp = vec4f(0.0);
1536
- for (var i = 0u; i < 4u; i++) { sp += (skinMats[joints0[i]] * pos4) * nw[i]; }
1537
- return camera.projection * camera.view * vec4f(sp.xyz, 1.0);
1538
- }
1539
-
1540
- @fragment fn fs() -> @location(0) vec4f {
1541
- return vec4f(pickId.modelId / 255.0, pickId.materialId / 255.0, 0.0, 1.0);
1542
- }
1543
- `,
1261
+ code: PICK_SHADER_WGSL,
1544
1262
  })
1545
1263
 
1546
1264
  this.pickPerFrameBindGroupLayout = this.device.createBindGroupLayout({
@@ -1691,12 +1409,16 @@ export class Engine {
1691
1409
 
1692
1410
  const depthTextureView = this.depthTexture.createView()
1693
1411
 
1412
+ // storeOp="discard" on MSAA views keeps per-sample data in Apple TBDR tile memory —
1413
+ // only the resolveTarget (hdrResolveTexture / maskResolveView) gets written to RAM.
1414
+ // With storeOp="store" Safari's Metal backend spills the full MS buffer every frame
1415
+ // (rgba16f × 4 samples on a 4K canvas ≈ 256 MB/frame of dead bandwidth).
1694
1416
  const colorAttachment: GPURenderPassColorAttachment = {
1695
1417
  view: this.multisampleTexture.createView(),
1696
1418
  resolveTarget: this.hdrResolveTexture.createView(),
1697
1419
  clearValue: { r: 0, g: 0, b: 0, a: 0 },
1698
1420
  loadOp: "clear",
1699
- storeOp: "store",
1421
+ storeOp: "discard",
1700
1422
  }
1701
1423
 
1702
1424
  const maskAttachment: GPURenderPassColorAttachment = {
@@ -1704,7 +1426,7 @@ export class Engine {
1704
1426
  resolveTarget: this.maskResolveView,
1705
1427
  clearValue: { r: 0, g: 0, b: 0, a: 0 },
1706
1428
  loadOp: "clear",
1707
- storeOp: "store",
1429
+ storeOp: "discard",
1708
1430
  }
1709
1431
 
1710
1432
  this.renderPassDescriptor = {
@@ -1714,7 +1436,8 @@ export class Engine {
1714
1436
  view: depthTextureView,
1715
1437
  depthClearValue: 1.0,
1716
1438
  depthLoadOp: "clear",
1717
- depthStoreOp: "store",
1439
+ // Main-pass depth is not sampled later (shadow uses its own map, composite is depthless).
1440
+ depthStoreOp: "discard",
1718
1441
  stencilClearValue: 0,
1719
1442
  stencilLoadOp: "clear",
1720
1443
  stencilStoreOp: "discard",
@@ -2557,6 +2280,26 @@ export class Engine {
2557
2280
  currentIndexOffset += indexCount
2558
2281
  }
2559
2282
 
2283
+ // Sort so the opaque bucket is emitted in the order the stencil-based
2284
+ // see-through-hair effect requires: {non-hair, non-eye} → {eye} → {hair}.
2285
+ // Eye writes stencil=EYE_VALUE; hair's pipeline stencil-tests "not equal" so
2286
+ // it skips eye pixels; a follow-up hairOverEyes pass (see renderOneModel)
2287
+ // re-fills those skipped pixels alpha-blended. Array.sort is stable in
2288
+ // ES2019+, so within a bucket the PMX material order is preserved.
2289
+ const typeOrder: Record<DrawCallType, number> = {
2290
+ opaque: 0,
2291
+ "opaque-outline": 1,
2292
+ transparent: 2,
2293
+ "transparent-outline": 3,
2294
+ ground: 4,
2295
+ }
2296
+ const presetRank = (p: MaterialPreset): number => (p === "hair" ? 2 : p === "eye" ? 1 : 0)
2297
+ inst.drawCalls.sort((a, b) => {
2298
+ const ta = typeOrder[a.type] - typeOrder[b.type]
2299
+ if (ta !== 0) return ta
2300
+ return presetRank(a.preset) - presetRank(b.preset)
2301
+ })
2302
+
2560
2303
  for (const d of inst.drawCalls) {
2561
2304
  if (d.type === "opaque") inst.shadowDrawCalls.push(d)
2562
2305
  }
@@ -2635,20 +2378,7 @@ export class Engine {
2635
2378
  })
2636
2379
  const module = this.device.createShaderModule({
2637
2380
  label: "mipmap blit",
2638
- code: /* wgsl */ `
2639
- @group(0) @binding(0) var src: texture_2d<f32>;
2640
- @group(0) @binding(1) var samp: sampler;
2641
- @vertex fn vs(@builtin(vertex_index) vi: u32) -> @builtin(position) vec4f {
2642
- let x = f32((vi & 1u) << 2u) - 1.0;
2643
- let y = f32((vi & 2u) << 1u) - 1.0;
2644
- return vec4f(x, y, 0.0, 1.0);
2645
- }
2646
- @fragment fn fs(@builtin(position) p: vec4f) -> @location(0) vec4f {
2647
- let dstDims = vec2f(textureDimensions(src)) * 0.5;
2648
- let uv = p.xy / max(dstDims, vec2f(1.0));
2649
- return textureSampleLevel(src, samp, uv, 0.0);
2650
- }
2651
- `,
2381
+ code: MIPMAP_BLIT_SHADER_WGSL,
2652
2382
  })
2653
2383
  this.mipBlitPipeline = this.device.createRenderPipeline({
2654
2384
  label: "mipmap blit pipeline",
@@ -2919,7 +2649,9 @@ export class Engine {
2919
2649
  const compositeAttachment = (this.compositePassDescriptor.colorAttachments as GPURenderPassColorAttachment[])[0]
2920
2650
  compositeAttachment.view = this.context.getCurrentTexture().createView()
2921
2651
  const cpass = encoder.beginRenderPass(this.compositePassDescriptor)
2922
- cpass.setPipeline(this.compositePipeline)
2652
+ const compositePipeline =
2653
+ this.viewTransform.gamma === 1.0 ? this.compositePipelineIdentity : this.compositePipelineGamma
2654
+ cpass.setPipeline(compositePipeline)
2923
2655
  cpass.setBindGroup(0, this.compositeBindGroup)
2924
2656
  cpass.draw(3)
2925
2657
  cpass.end()
@@ -3020,12 +2752,38 @@ export class Engine {
3020
2752
  pass.setVertexBuffer(2, inst.weightsBuffer)
3021
2753
  pass.setIndexBuffer(inst.indexBuffer, "uint32")
3022
2754
 
2755
+ // Single stencil-reference set covers eye (write), hair (read not-equal),
2756
+ // and hairOverEyes (read equal). Non-stencil pipelines ignore the value.
2757
+ pass.setStencilReference(Engine.STENCIL_EYE_VALUE)
2758
+
3023
2759
  this.drawMaterials(pass, inst, "opaque")
3024
2760
  this.drawOutlines(pass, inst, "opaque-outline")
2761
+ this.drawHairOverEyes(pass, inst)
3025
2762
  this.drawMaterials(pass, inst, "transparent")
3026
2763
  this.drawOutlines(pass, inst, "transparent-outline")
3027
2764
  }
3028
2765
 
2766
+ /**
2767
+ * Second hair pass for the see-through-hair effect. Re-draws every hair opaque
2768
+ * draw using `hairOverEyesPipeline` — which stencil-matches `EYE_VALUE` and runs
2769
+ * the hair shader with `IS_OVER_EYES=true` so alpha is halved. depthWriteEnabled
2770
+ * is off, so the eye's depth stays authoritative for anything drawn after.
2771
+ */
2772
+ private drawHairOverEyes(pass: GPURenderPassEncoder, inst: ModelInstance): void {
2773
+ let bound = false
2774
+ for (const draw of inst.drawCalls) {
2775
+ if (draw.type !== "opaque" || draw.preset !== "hair" || !this.shouldRenderDrawCall(inst, draw)) continue
2776
+ if (!bound) {
2777
+ pass.setPipeline(this.hairOverEyesPipeline)
2778
+ pass.setBindGroup(0, this.perFrameBindGroup)
2779
+ pass.setBindGroup(1, inst.mainPerInstanceBindGroup)
2780
+ bound = true
2781
+ }
2782
+ pass.setBindGroup(2, draw.bindGroup)
2783
+ pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0)
2784
+ }
2785
+ }
2786
+
3029
2787
  private updateCameraUniforms() {
3030
2788
  const viewMatrix = this.camera.getViewMatrix()
3031
2789
  const projectionMatrix = this.camera.getProjectionMatrix()