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/dist/engine.js CHANGED
@@ -3,18 +3,33 @@ import { Mat4, Vec3 } from "./math";
3
3
  import { PmxLoader } from "./pmx-loader";
4
4
  import { Physics } from "./physics";
5
5
  import { createFetchAssetReader, createFileMapAssetReader, deriveBasePathFromPmxPath, fileListToMap, findFirstPmxFileInList, joinAssetPath, normalizeAssetPath, } from "./asset-reader";
6
- import { DEFAULT_SHADER_WGSL } from "./shaders/default";
6
+ import { DEFAULT_SHADER_WGSL } from "./shaders/materials/default";
7
+ import { FACE_SHADER_WGSL } from "./shaders/materials/face";
8
+ import { HAIR_SHADER_WGSL } from "./shaders/materials/hair";
9
+ import { CLOTH_SMOOTH_SHADER_WGSL } from "./shaders/materials/cloth_smooth";
10
+ import { CLOTH_ROUGH_SHADER_WGSL } from "./shaders/materials/cloth_rough";
11
+ import { METAL_SHADER_WGSL } from "./shaders/materials/metal";
12
+ import { BODY_SHADER_WGSL } from "./shaders/materials/body";
13
+ import { EYE_SHADER_WGSL } from "./shaders/materials/eye";
14
+ import { STOCKINGS_SHADER_WGSL } from "./shaders/materials/stockings";
7
15
  import { BRDF_LUT_SIZE, BRDF_LUT_BAKE_WGSL } from "./shaders/dfg_lut";
8
16
  import { LTC_MAG_LUT_SIZE, LTC_MAG_LUT_DATA } from "./shaders/ltc_mag_lut";
9
- import { FACE_SHADER_WGSL } from "./shaders/face";
10
- import { HAIR_SHADER_WGSL } from "./shaders/hair";
11
- import { CLOTH_SMOOTH_SHADER_WGSL } from "./shaders/cloth_smooth";
12
- import { CLOTH_ROUGH_SHADER_WGSL } from "./shaders/cloth_rough";
13
- import { METAL_SHADER_WGSL } from "./shaders/metal";
14
- import { BODY_SHADER_WGSL } from "./shaders/body";
15
- import { EYE_SHADER_WGSL } from "./shaders/eye";
16
- import { STOCKINGS_SHADER_WGSL } from "./shaders/stockings";
17
- import { resolvePreset } from "./shaders/classify";
17
+ import { SHADOW_DEPTH_SHADER_WGSL } from "./shaders/passes/shadow";
18
+ import { GROUND_SHADOW_SHADER_WGSL } from "./shaders/passes/ground";
19
+ import { OUTLINE_SHADER_WGSL } from "./shaders/passes/outline";
20
+ import { BLOOM_BLIT_SHADER_WGSL, BLOOM_DOWNSAMPLE_SHADER_WGSL, BLOOM_UPSAMPLE_SHADER_WGSL, } from "./shaders/passes/bloom";
21
+ import { COMPOSITE_SHADER_WGSL } from "./shaders/passes/composite";
22
+ import { PICK_SHADER_WGSL } from "./shaders/passes/pick";
23
+ import { MIPMAP_BLIT_SHADER_WGSL } from "./shaders/passes/mipmap";
24
+ function resolvePreset(materialName, map) {
25
+ if (!map)
26
+ return "default";
27
+ for (const [preset, names] of Object.entries(map)) {
28
+ if (names && names.includes(materialName))
29
+ return preset;
30
+ }
31
+ return "default";
32
+ }
18
33
  export const DEFAULT_BLOOM_OPTIONS = {
19
34
  enabled: true,
20
35
  threshold: 0.5,
@@ -50,7 +65,7 @@ export class Engine {
50
65
  this.lightData = new Float32Array(64);
51
66
  this.lightCount = 0;
52
67
  this.resizeObserver = null;
53
- // [exposure, gamma, _, _, bloomTint.x, bloomTint.y, bloomTint.z, bloomIntensity]
68
+ // [exposure, invGamma, _, _, bloomTint.x, bloomTint.y, bloomTint.z, bloomIntensity]
54
69
  this.compositeUniformData = new Float32Array(8);
55
70
  this.bloomBlitUniformData = new Float32Array(4);
56
71
  this.bloomUpsampleUniformData = new Float32Array(4);
@@ -200,7 +215,10 @@ export class Engine {
200
215
  const effIntensity = b.enabled ? b.intensity : 0.0;
201
216
  const u = this.compositeUniformData;
202
217
  u[0] = v.exposure;
203
- u[1] = Math.max(v.gamma, 1e-4);
218
+ // Store 1/gamma so the shader avoids a per-pixel divide. Safari's Metal
219
+ // compiler doesn't fold `pow(x, 1/g)` into identity when g=1, so also emit
220
+ // a uniform branch that skips the pow entirely in the common case.
221
+ u[1] = 1.0 / Math.max(v.gamma, 1e-4);
204
222
  u[2] = 0.0;
205
223
  u[3] = 0.0;
206
224
  u[4] = b.color.x;
@@ -536,6 +554,9 @@ export class Engine {
536
554
  depthCompare: "less-equal",
537
555
  },
538
556
  });
557
+ // Hair opaque: stencil != EYE_VALUE so fragments on top of eyes are skipped entirely —
558
+ // depth and color stay as the eye wrote them; the follow-up hairOverEyesPipeline then
559
+ // draws those skipped fragments alpha-blended so the eye reads through the hair.
539
560
  this.hairPipeline = this.createRenderPipeline({
540
561
  label: "hair NPR pipeline",
541
562
  layout: mainPipelineLayout,
@@ -547,8 +568,37 @@ export class Engine {
547
568
  format: "depth24plus-stencil8",
548
569
  depthWriteEnabled: true,
549
570
  depthCompare: "less-equal",
571
+ stencilFront: { compare: "not-equal", failOp: "keep", depthFailOp: "keep", passOp: "keep" },
572
+ stencilBack: { compare: "not-equal", failOp: "keep", depthFailOp: "keep", passOp: "keep" },
573
+ stencilReadMask: 0xff,
574
+ stencilWriteMask: 0,
550
575
  },
551
576
  });
577
+ // Hair-over-eyes: same shader with IS_OVER_EYES=true so alpha is halved at compile time.
578
+ // Only fragments where eye stencil == EYE_VALUE pass; depth test still culls fragments
579
+ // that are further from camera than the eye, so hair behind the eye never shows through.
580
+ // depthWriteEnabled=false keeps the eye's depth authoritative for everything drawn after.
581
+ this.hairOverEyesPipeline = this.device.createRenderPipeline({
582
+ label: "hair over eyes pipeline",
583
+ layout: mainPipelineLayout,
584
+ vertex: { module: hairShaderModule, buffers: fullVertexBuffers },
585
+ fragment: {
586
+ module: hairShaderModule,
587
+ constants: { IS_OVER_EYES: 1 },
588
+ targets: sceneTargets,
589
+ },
590
+ primitive: { cullMode: "none" },
591
+ depthStencil: {
592
+ format: "depth24plus-stencil8",
593
+ depthWriteEnabled: false,
594
+ depthCompare: "less-equal",
595
+ stencilFront: { compare: "equal", failOp: "keep", depthFailOp: "keep", passOp: "keep" },
596
+ stencilBack: { compare: "equal", failOp: "keep", depthFailOp: "keep", passOp: "keep" },
597
+ stencilReadMask: 0xff,
598
+ stencilWriteMask: 0,
599
+ },
600
+ multisample: { count: Engine.MULTISAMPLE_COUNT },
601
+ });
552
602
  this.clothSmoothPipeline = this.createRenderPipeline({
553
603
  label: "cloth smooth NPR pipeline",
554
604
  layout: mainPipelineLayout,
@@ -601,17 +651,30 @@ export class Engine {
601
651
  depthCompare: "less-equal",
602
652
  },
603
653
  });
654
+ // Eye: stamps stencil = EYE_VALUE on every fragment it writes. Later hair passes read
655
+ // this stamp to split into "draw normally (not over eye)" vs "draw alpha-blended".
656
+ // cullMode="front" + small negative depthBias is the MMD post-alpha-eye trick: only the
657
+ // back half of the eye sphere renders, it passes depth against the face (via bias) when
658
+ // viewed from the front, and it gets culled when viewed from behind — so eye fragments
659
+ // can't leak through the back of the head without needing a per-model skull occluder.
604
660
  this.eyePipeline = this.createRenderPipeline({
605
661
  label: "eye pipeline",
606
662
  layout: mainPipelineLayout,
607
663
  shaderModule: eyeShaderModule,
608
664
  vertexBuffers: fullVertexBuffers,
609
665
  fragmentTargets: sceneTargets,
610
- cullMode: "none",
666
+ cullMode: "front",
611
667
  depthStencil: {
612
668
  format: "depth24plus-stencil8",
613
669
  depthWriteEnabled: true,
614
670
  depthCompare: "less-equal",
671
+ depthBias: -0.00005,
672
+ depthBiasSlopeScale: 0.0,
673
+ depthBiasClamp: 0.0,
674
+ stencilFront: { compare: "always", failOp: "keep", depthFailOp: "keep", passOp: "replace" },
675
+ stencilBack: { compare: "always", failOp: "keep", depthFailOp: "keep", passOp: "replace" },
676
+ stencilReadMask: 0xff,
677
+ stencilWriteMask: 0xff,
615
678
  },
616
679
  });
617
680
  this.stockingsPipeline = this.createRenderPipeline({
@@ -640,21 +703,7 @@ export class Engine {
640
703
  });
641
704
  const shadowShader = this.device.createShaderModule({
642
705
  label: "shadow depth",
643
- code: /* wgsl */ `
644
- struct LightVP { viewProj: mat4x4f, };
645
- @group(0) @binding(0) var<uniform> lp: LightVP;
646
- @group(0) @binding(1) var<storage, read> skinMats: array<mat4x4f>;
647
- @vertex fn vs(@location(0) position: vec3f, @location(1) normal: vec3f, @location(2) uv: vec2f,
648
- @location(3) joints0: vec4<u32>, @location(4) weights0: vec4<f32>) -> @builtin(position) vec4f {
649
- let pos4 = vec4f(position, 1.0);
650
- let ws = weights0.x + weights0.y + weights0.z + weights0.w;
651
- let inv = select(1.0, 1.0 / ws, ws > 0.0001);
652
- let nw = select(vec4f(1.0,0.0,0.0,0.0), weights0 * inv, ws > 0.0001);
653
- var sp = vec4f(0.0);
654
- for (var i = 0u; i < 4u; i++) { sp += (skinMats[joints0[i]] * pos4) * nw[i]; }
655
- return lp.viewProj * vec4f(sp.xyz, 1.0);
656
- }
657
- `,
706
+ code: SHADOW_DEPTH_SHADER_WGSL,
658
707
  });
659
708
  this.shadowDepthPipeline = this.device.createRenderPipeline({
660
709
  label: "shadow depth pipeline",
@@ -711,99 +760,7 @@ export class Engine {
711
760
  });
712
761
  const groundShadowShader = this.device.createShaderModule({
713
762
  label: "ground shadow",
714
- code: /* wgsl */ `
715
- struct CameraUniforms { view: mat4x4f, projection: mat4x4f, viewPos: vec3f, _p: f32, };
716
- struct Light { direction: vec4f, color: vec4f, };
717
- struct LightUniforms { ambientColor: vec4f, lights: array<Light, 4>, };
718
- struct GroundShadowMat {
719
- diffuseColor: vec3f, fadeStart: f32,
720
- fadeEnd: f32, shadowStrength: f32, pcfTexel: f32, gridSpacing: f32,
721
- gridLineWidth: f32, gridLineOpacity: f32, noiseStrength: f32, _pad: f32,
722
- gridLineColor: vec3f, _pad2: f32,
723
- };
724
- struct LightVP { viewProj: mat4x4f, };
725
- @group(0) @binding(0) var<uniform> camera: CameraUniforms;
726
- @group(0) @binding(1) var<uniform> light: LightUniforms;
727
- @group(0) @binding(2) var shadowMap: texture_depth_2d;
728
- @group(0) @binding(3) var shadowSampler: sampler_comparison;
729
- @group(0) @binding(4) var<uniform> material: GroundShadowMat;
730
- @group(0) @binding(5) var<uniform> lightVP: LightVP;
731
-
732
- // Hash-based noise for frosted/matte surface
733
- fn hash2(p: vec2f) -> f32 {
734
- var p3 = fract(vec3f(p.x, p.y, p.x) * 0.1031);
735
- p3 += dot(p3, vec3f(p3.y + 33.33, p3.z + 33.33, p3.x + 33.33));
736
- return fract((p3.x + p3.y) * p3.z);
737
- }
738
- fn valueNoise(p: vec2f) -> f32 {
739
- let i = floor(p);
740
- let f = fract(p);
741
- let u = f * f * (3.0 - 2.0 * f);
742
- return mix(mix(hash2(i), hash2(i + vec2f(1.0, 0.0)), u.x),
743
- mix(hash2(i + vec2f(0.0, 1.0)), hash2(i + vec2f(1.0, 1.0)), u.x), u.y);
744
- }
745
- fn fbmNoise(p: vec2f) -> f32 {
746
- var v = 0.0;
747
- var a = 0.5;
748
- var pp = p;
749
- for (var i = 0; i < 4; i++) {
750
- v += a * valueNoise(pp);
751
- pp *= 2.0;
752
- a *= 0.5;
753
- }
754
- return v;
755
- }
756
-
757
- struct VO { @builtin(position) position: vec4f, @location(0) worldPos: vec3f, @location(1) normal: vec3f, };
758
- @vertex fn vs(@location(0) position: vec3f, @location(1) normal: vec3f, @location(2) uv: vec2f) -> VO {
759
- var o: VO; o.worldPos = position; o.normal = normal;
760
- o.position = camera.projection * camera.view * vec4f(position, 1.0); return o;
761
- }
762
- struct FSOut { @location(0) color: vec4f, @location(1) mask: f32 };
763
- @fragment fn fs(i: VO) -> FSOut {
764
- let n = normalize(i.normal);
765
- let centerDist = length(i.worldPos.xz);
766
- let edgeFade = 1.0 - smoothstep(0.0, 1.0, clamp((centerDist - material.fadeStart) / max(material.fadeEnd - material.fadeStart, 0.001), 0.0, 1.0));
767
-
768
- // Shadow sampling
769
- let lclip = lightVP.viewProj * vec4f(i.worldPos, 1.0);
770
- let ndc = lclip.xyz / max(lclip.w, 1e-6);
771
- let suv = vec2f(ndc.x * 0.5 + 0.5, 0.5 - ndc.y * 0.5);
772
- let suv_c = clamp(suv, vec2f(0.02), vec2f(0.98));
773
- let st = material.pcfTexel;
774
- let compareZ = ndc.z - 0.0035;
775
- var vis = 0.0;
776
- for (var y = -2; y <= 2; y++) {
777
- for (var x = -2; x <= 2; x++) {
778
- vis += textureSampleCompare(shadowMap, shadowSampler, suv_c + vec2f(f32(x), f32(y)) * st, compareZ);
779
- }
780
- }
781
- vis *= 0.04;
782
-
783
- // Frosted/matte micro-texture (磨砂)
784
- let noiseVal = fbmNoise(i.worldPos.xz * 3.0);
785
- let noiseTint = 1.0 + (noiseVal - 0.5) * material.noiseStrength;
786
-
787
- // Grid lines — anti-aliased via screen-space derivatives
788
- let gp = i.worldPos.xz / material.gridSpacing;
789
- let gridFrac = abs(fract(gp - 0.5) - 0.5);
790
- let gridDeriv = fwidth(gp);
791
- let halfLine = material.gridLineWidth * 0.5;
792
- let gridLine = 1.0 - min(
793
- smoothstep(halfLine - gridDeriv.x, halfLine + gridDeriv.x, gridFrac.x),
794
- smoothstep(halfLine - gridDeriv.y, halfLine + gridDeriv.y, gridFrac.y)
795
- );
796
- 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);
797
- let dark = (1.0 - vis) * material.shadowStrength;
798
- var baseColor = material.diffuseColor * sun * (1.0 - dark * 0.65);
799
- baseColor *= noiseTint;
800
- let finalColor = mix(baseColor, material.gridLineColor, gridLine * material.gridLineOpacity * edgeFade);
801
- var out: FSOut;
802
- out.color = vec4f(finalColor * edgeFade, edgeFade);
803
- out.mask = 0.0;
804
- return out;
805
- }
806
- `,
763
+ code: GROUND_SHADOW_SHADER_WGSL,
807
764
  });
808
765
  this.groundShadowPipeline = this.createRenderPipeline({
809
766
  label: "ground shadow pipeline",
@@ -843,90 +800,7 @@ export class Engine {
843
800
  });
844
801
  const outlineShaderModule = this.device.createShaderModule({
845
802
  label: "outline shaders",
846
- code: /* wgsl */ `
847
- struct CameraUniforms {
848
- view: mat4x4f,
849
- projection: mat4x4f,
850
- viewPos: vec3f,
851
- _padding: f32,
852
- };
853
-
854
- struct MaterialUniforms {
855
- edgeColor: vec4f,
856
- edgeSize: f32,
857
- _padding1: f32,
858
- _padding2: f32,
859
- _padding3: f32,
860
- };
861
-
862
- // group 0: per-frame
863
- @group(0) @binding(0) var<uniform> camera: CameraUniforms;
864
- // group 1: per-instance
865
- @group(1) @binding(0) var<storage, read> skinMats: array<mat4x4f>;
866
- // group 2: per-material
867
- @group(2) @binding(0) var<uniform> material: MaterialUniforms;
868
-
869
- struct VertexOutput {
870
- @builtin(position) position: vec4f,
871
- };
872
-
873
- @vertex fn vs(
874
- @location(0) position: vec3f,
875
- @location(1) normal: vec3f,
876
- @location(3) joints0: vec4<u32>,
877
- @location(4) weights0: vec4<f32>
878
- ) -> VertexOutput {
879
- var output: VertexOutput;
880
- let pos4 = vec4f(position, 1.0);
881
-
882
- let weightSum = weights0.x + weights0.y + weights0.z + weights0.w;
883
- let invWeightSum = select(1.0, 1.0 / weightSum, weightSum > 0.0001);
884
- let normalizedWeights = select(vec4f(1.0, 0.0, 0.0, 0.0), weights0 * invWeightSum, weightSum > 0.0001);
885
-
886
- var skinnedPos = vec4f(0.0, 0.0, 0.0, 0.0);
887
- var skinnedNrm = vec3f(0.0, 0.0, 0.0);
888
- for (var i = 0u; i < 4u; i++) {
889
- let j = joints0[i];
890
- let w = normalizedWeights[i];
891
- let m = skinMats[j];
892
- skinnedPos += (m * pos4) * w;
893
- let r3 = mat3x3f(m[0].xyz, m[1].xyz, m[2].xyz);
894
- skinnedNrm += (r3 * normal) * w;
895
- }
896
- let worldPos = skinnedPos.xyz;
897
- let worldNormal = normalize(skinnedNrm);
898
-
899
- // Screen-space outline extrusion — MMD-style pixel-stable edge line.
900
- // 1. Project position and normal-as-direction to clip space.
901
- // 2. Normalize the 2D clip-space normal, aspect-compensated so "one pixel horizontally"
902
- // matches "one pixel vertically" (otherwise wide viewports squash the outline in X).
903
- // 3. Offset clip-space xy by (normal * edgeSize * edgeScale), then multiply by w
904
- // so the perspective divide cancels out → offset stays constant in NDC regardless
905
- // of depth, matching how MMD / babylon-mmd style outlines look identical when zooming.
906
- // 4. edgeScale is in NDC-y units per PMX edgeSize. ≈ 0.006 gives ~3px at 1080p; it's
907
- // tied to viewport HEIGHT so resizing the window keeps pixel thickness stable.
908
- let viewProj = camera.projection * camera.view;
909
- let clipPos = viewProj * vec4f(worldPos, 1.0);
910
- let clipNormal = (viewProj * vec4f(worldNormal, 0.0)).xy;
911
- // projection is column-major: proj[0][0] = 1/(aspect·tan(fov/2)), proj[1][1] = 1/tan(fov/2).
912
- // Ratio proj[1][1]/proj[0][0] recovers the viewport aspect (width/height).
913
- let aspect = camera.projection[1][1] / camera.projection[0][0];
914
- let pixelDir = normalize(vec2f(clipNormal.x * aspect, clipNormal.y));
915
- let ndcDir = vec2f(pixelDir.x / aspect, pixelDir.y);
916
- let edgeScale = 0.0016;
917
- let offset = ndcDir * material.edgeSize * edgeScale * clipPos.w;
918
- output.position = vec4f(clipPos.xy + offset, clipPos.z, clipPos.w);
919
- return output;
920
- }
921
-
922
- struct FSOut { @location(0) color: vec4f, @location(1) mask: f32 };
923
- @fragment fn fs() -> FSOut {
924
- var out: FSOut;
925
- out.color = material.edgeColor;
926
- out.mask = 1.0;
927
- return out;
928
- }
929
- `,
803
+ code: OUTLINE_SHADER_WGSL,
930
804
  });
931
805
  this.outlinePipeline = this.createRenderPipeline({
932
806
  label: "outline pipeline",
@@ -940,6 +814,13 @@ export class Engine {
940
814
  // Don’t write outline into depth buffer — stops z-fighting / black cracks vs body (MMD-style; body depth stays authoritative)
941
815
  depthWriteEnabled: false,
942
816
  depthCompare: "less-equal",
817
+ // Skip fragments where the eye stamped stencil=EYE_VALUE. Those pixels are owned by
818
+ // the see-through-hair blend (hair-over-eyes), so letting the outline's near-black
819
+ // edge color overwrite them would re-introduce the dark almond we just killed.
820
+ stencilFront: { compare: "not-equal", failOp: "keep", depthFailOp: "keep", passOp: "keep" },
821
+ stencilBack: { compare: "not-equal", failOp: "keep", depthFailOp: "keep", passOp: "keep" },
822
+ stencilReadMask: 0xff,
823
+ stencilWriteMask: 0,
943
824
  },
944
825
  });
945
826
  // ─── Bloom (EEVEE 3.6 pyramid): blit(Karis prefilter) → 13-tap downsamples → 9-tap tent upsamples ───
@@ -987,127 +868,17 @@ export class Engine {
987
868
  { binding: 3, visibility: GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } },
988
869
  ],
989
870
  });
990
- const bloomFullscreenVs = /* wgsl */ `
991
- @vertex fn vs(@builtin(vertex_index) vi: u32) -> @builtin(position) vec4f {
992
- let x = f32((vi & 1u) << 2u) - 1.0;
993
- let y = f32((vi & 2u) << 1u) - 1.0;
994
- return vec4f(x, y, 0.0, 1.0);
995
- }
996
- `;
997
- // Blit: full-res HDR → half-res. Karis 4-tap firefly average + EEVEE quadratic knee threshold + clamp.
998
871
  const bloomBlitShader = this.device.createShaderModule({
999
872
  label: "bloom blit (Karis prefilter)",
1000
- code: `${bloomFullscreenVs}
1001
- @group(0) @binding(0) var hdrTex: texture_2d<f32>;
1002
- @group(0) @binding(1) var<uniform> prefilter: vec4<f32>; // threshold, knee, clamp, _unused
1003
- @group(0) @binding(2) var maskTex: texture_2d<f32>;
1004
-
1005
- fn luminance(c: vec3f) -> f32 {
1006
- return dot(max(c, vec3f(0.0)), vec3f(0.2126, 0.7152, 0.0722));
1007
- }
1008
- fn fetch(c: vec2<i32>, clampV: f32) -> vec3f {
1009
- let d = vec2<i32>(textureDimensions(hdrTex));
1010
- let cc = clamp(c, vec2<i32>(0), d - vec2<i32>(1));
1011
- let s = textureLoad(hdrTex, cc, 0);
1012
- // Scene pass uses src-alpha blend with clear alpha 0 → premultiplied. Unpremultiply.
1013
- let rgb = max(s.rgb / max(s.a, 1e-6), vec3f(0.0));
1014
- // Bloom mask: MRT r8unorm written by material shaders (1.0 = bloom, 0.0 = skip).
1015
- let mask = textureLoad(maskTex, cc, 0).r;
1016
- let masked = rgb * mask;
1017
- // Blender: clamp each tap BEFORE Karis average (eevee_bloom: color = min(clampIntensity, color)).
1018
- return select(masked, min(masked, vec3f(clampV)), clampV > 0.0);
1019
- }
1020
-
1021
- @fragment fn fs(@builtin(position) p: vec4f) -> @location(0) vec4f {
1022
- let dst = vec2<i32>(p.xy - vec2f(0.5));
1023
- let base = dst * 2;
1024
- let clampV = prefilter.z;
1025
- let a = fetch(base + vec2<i32>(0, 0), clampV);
1026
- let b = fetch(base + vec2<i32>(1, 0), clampV);
1027
- let c = fetch(base + vec2<i32>(0, 1), clampV);
1028
- let d = fetch(base + vec2<i32>(1, 1), clampV);
1029
- // Karis partial average: weight each tap by 1/(1+luma) — suppresses fireflies.
1030
- let wa = 1.0 / (1.0 + luminance(a));
1031
- let wb = 1.0 / (1.0 + luminance(b));
1032
- let wc = 1.0 / (1.0 + luminance(c));
1033
- let wd = 1.0 / (1.0 + luminance(d));
1034
- let avg = (a * wa + b * wb + c * wc + d * wd) / max(wa + wb + wc + wd, 1e-6);
1035
- // EEVEE quadratic threshold (brightness = max-channel, then soft-knee curve).
1036
- let bright = max(avg.r, max(avg.g, avg.b));
1037
- let soft = clamp(bright - prefilter.x + prefilter.y, 0.0, 2.0 * prefilter.y);
1038
- let q = (soft * soft) / (4.0 * max(prefilter.y, 1e-4) + 1e-6);
1039
- let contrib = max(q, bright - prefilter.x) / max(bright, 1e-4);
1040
- return vec4f(max(avg * contrib, vec3f(0.0)), 1.0);
1041
- }
1042
- `,
873
+ code: BLOOM_BLIT_SHADER_WGSL,
1043
874
  });
1044
- // Downsample: Jimenez/COD 13-tap dual-box — 5 weighted 2×2 averages, rejects nyquist ringing.
1045
875
  const bloomDownsampleShader = this.device.createShaderModule({
1046
876
  label: "bloom downsample 13-tap",
1047
- code: `${bloomFullscreenVs}
1048
- @group(0) @binding(0) var srcTex: texture_2d<f32>;
1049
- @group(0) @binding(1) var srcSamp: sampler;
1050
-
1051
- fn samp(uv: vec2f, off: vec2f) -> vec3f {
1052
- return textureSampleLevel(srcTex, srcSamp, uv + off, 0.0).rgb;
1053
- }
1054
-
1055
- @fragment fn fs(@builtin(position) p: vec4f) -> @location(0) vec4f {
1056
- let srcDims = vec2f(textureDimensions(srcTex));
1057
- let t = 1.0 / srcDims;
1058
- // fragCoord.xy reports pixel centers (e.g. 0.5,0.5 for first pixel) — divide by dst dims directly.
1059
- let dstDims = srcDims * 0.5;
1060
- let uv = p.xy / max(dstDims, vec2f(1.0));
1061
- let A = samp(uv, t * vec2f(-2.0, -2.0));
1062
- let B = samp(uv, t * vec2f( 0.0, -2.0));
1063
- let C = samp(uv, t * vec2f( 2.0, -2.0));
1064
- let D = samp(uv, t * vec2f(-1.0, -1.0));
1065
- let E = samp(uv, t * vec2f( 1.0, -1.0));
1066
- let F = samp(uv, t * vec2f(-2.0, 0.0));
1067
- let G = samp(uv, t * vec2f( 0.0, 0.0));
1068
- let H = samp(uv, t * vec2f( 2.0, 0.0));
1069
- let I = samp(uv, t * vec2f(-1.0, 1.0));
1070
- let J = samp(uv, t * vec2f( 1.0, 1.0));
1071
- let K = samp(uv, t * vec2f(-2.0, 2.0));
1072
- let L = samp(uv, t * vec2f( 0.0, 2.0));
1073
- let M = samp(uv, t * vec2f( 2.0, 2.0));
1074
- var o = (D + E + I + J) * (0.5 / 4.0);
1075
- o = o + (A + B + G + F) * (0.125 / 4.0);
1076
- o = o + (B + C + H + G) * (0.125 / 4.0);
1077
- o = o + (F + G + L + K) * (0.125 / 4.0);
1078
- o = o + (G + H + M + L) * (0.125 / 4.0);
1079
- return vec4f(o, 1.0);
1080
- }
1081
- `,
877
+ code: BLOOM_DOWNSAMPLE_SHADER_WGSL,
1082
878
  });
1083
- // Upsample: 9-tap tent, progressively added to matching downsample mip. Blender radius = sample scale.
1084
879
  const bloomUpsampleShader = this.device.createShaderModule({
1085
880
  label: "bloom upsample 9-tap tent",
1086
- code: `${bloomFullscreenVs}
1087
- @group(0) @binding(0) var srcTex: texture_2d<f32>; // coarser accumulator
1088
- @group(0) @binding(1) var baseTex: texture_2d<f32>; // matching downsample mip
1089
- @group(0) @binding(2) var srcSamp: sampler;
1090
- @group(0) @binding(3) var<uniform> upU: vec4<f32>; // sampleScale, _, _, _
1091
-
1092
- @fragment fn fs(@builtin(position) p: vec4f) -> @location(0) vec4f {
1093
- let srcDims = vec2f(textureDimensions(srcTex));
1094
- let baseDims = vec2f(textureDimensions(baseTex));
1095
- let uv = p.xy / max(baseDims, vec2f(1.0));
1096
- let t = upU.x / srcDims;
1097
- var o = textureSampleLevel(srcTex, srcSamp, uv + t * vec2f(-1.0, -1.0), 0.0).rgb * 1.0;
1098
- o = o + textureSampleLevel(srcTex, srcSamp, uv + t * vec2f( 0.0, -1.0), 0.0).rgb * 2.0;
1099
- o = o + textureSampleLevel(srcTex, srcSamp, uv + t * vec2f( 1.0, -1.0), 0.0).rgb * 1.0;
1100
- o = o + textureSampleLevel(srcTex, srcSamp, uv + t * vec2f(-1.0, 0.0), 0.0).rgb * 2.0;
1101
- o = o + textureSampleLevel(srcTex, srcSamp, uv + t * vec2f( 0.0, 0.0), 0.0).rgb * 4.0;
1102
- o = o + textureSampleLevel(srcTex, srcSamp, uv + t * vec2f( 1.0, 0.0), 0.0).rgb * 2.0;
1103
- o = o + textureSampleLevel(srcTex, srcSamp, uv + t * vec2f(-1.0, 1.0), 0.0).rgb * 1.0;
1104
- o = o + textureSampleLevel(srcTex, srcSamp, uv + t * vec2f( 0.0, 1.0), 0.0).rgb * 2.0;
1105
- o = o + textureSampleLevel(srcTex, srcSamp, uv + t * vec2f( 1.0, 1.0), 0.0).rgb * 1.0;
1106
- o = o * (1.0 / 16.0);
1107
- let base = textureSampleLevel(baseTex, srcSamp, uv, 0.0).rgb;
1108
- return vec4f(o + base, 1.0);
1109
- }
1110
- `,
881
+ code: BLOOM_UPSAMPLE_SHADER_WGSL,
1111
882
  });
1112
883
  const bloomBlitLayout = this.device.createPipelineLayout({ bindGroupLayouts: [this.bloomBlitBindGroupLayout] });
1113
884
  const bloomDownLayout = this.device.createPipelineLayout({
@@ -1154,67 +925,25 @@ export class Engine {
1154
925
  });
1155
926
  const compositeShader = this.device.createShaderModule({
1156
927
  label: "composite shader",
1157
- code: /* wgsl */ `
1158
- @group(0) @binding(0) var hdrTex: texture_2d<f32>;
1159
- @group(0) @binding(1) var bloomTex: texture_2d<f32>; // bloomUpTexture mip 0 (full pyramid top)
1160
- @group(0) @binding(2) var bloomSamp: sampler;
1161
- @group(0) @binding(3) var<uniform> viewU: array<vec4<f32>, 2>;
1162
- // viewU[0] = (exposure, gamma, _, _); viewU[1] = (tint.rgb, intensity)
1163
-
1164
- fn filmic(x: f32) -> f32 {
1165
- // Re-fit against Blender 3.6 Filmic MHC anchors (sobotka/filmic-blender
1166
- // look_medium-high-contrast.spi1d). Previous curve was compressed:
1167
- // midtones too bright, highlights too dim — flattened contrast, read
1168
- // as "washed-out" on saturated surfaces (hair especially).
1169
- // Reference checkpoints: linear 0.18 → ~0.395, linear 1.0 → ~0.83.
1170
- var lut = array<f32, 14>(
1171
- 0.0028, 0.0068, 0.0151, 0.0313, 0.0610, 0.1120, 0.1920,
1172
- 0.3060, 0.4590, 0.6310, 0.8200, 0.9070, 0.9620, 0.9890
1173
- );
1174
- let t = clamp(log2(max(x, 1e-10)) + 10.0, 0.0, 13.0);
1175
- let i = u32(t);
1176
- let j = min(i + 1u, 13u);
1177
- return mix(lut[i], lut[j], t - f32(i));
1178
- }
1179
-
1180
- @vertex fn vs(@builtin(vertex_index) vi: u32) -> @builtin(position) vec4f {
1181
- let x = f32((vi & 1u) << 2u) - 1.0;
1182
- let y = f32((vi & 2u) << 1u) - 1.0;
1183
- return vec4f(x, y, 0.0, 1.0);
1184
- }
1185
-
1186
- @fragment fn fs(@builtin(position) fragCoord: vec4f) -> @location(0) vec4f {
1187
- let hdr = textureLoad(hdrTex, vec2<i32>(fragCoord.xy), 0);
1188
- let a = max(hdr.a, 1e-6);
1189
- let straight = hdr.rgb / a;
1190
- let fullSz = vec2f(textureDimensions(hdrTex));
1191
- let bloomSz = vec2f(textureDimensions(bloomTex));
1192
- // Bloom is at half-res (pyramid mip 0). Sampler interpolates back to full-res UVs.
1193
- // fragCoord.xy is already at pixel center (e.g. 0.5, 0.5 for first pixel).
1194
- let bloomUv = fragCoord.xy / max(fullSz, vec2f(1.0));
1195
- let tint = viewU[1].xyz;
1196
- let intensity = viewU[1].w;
1197
- let bloom = textureSampleLevel(bloomTex, bloomSamp, bloomUv, 0.0).rgb * tint * intensity;
1198
- let combined = straight + bloom;
1199
- let exposed = combined * exp2(viewU[0].x);
1200
- let tm = vec3f(filmic(exposed.r), filmic(exposed.g), filmic(exposed.b));
1201
- let g = max(viewU[0].y, 1e-4);
1202
- let disp = pow(max(tm, vec3f(0.0)), vec3f(1.0 / g));
1203
- return vec4f(disp * hdr.a, hdr.a);
1204
- }
1205
- `,
928
+ code: COMPOSITE_SHADER_WGSL,
929
+ });
930
+ const compositePipelineLayout = this.device.createPipelineLayout({
931
+ bindGroupLayouts: [this.compositeBindGroupLayout],
1206
932
  });
1207
- this.compositePipeline = this.device.createRenderPipeline({
1208
- label: "composite pipeline",
1209
- layout: this.device.createPipelineLayout({ bindGroupLayouts: [this.compositeBindGroupLayout] }),
933
+ const makeCompositePipeline = (applyGamma, label) => this.device.createRenderPipeline({
934
+ label,
935
+ layout: compositePipelineLayout,
1210
936
  vertex: { module: compositeShader, entryPoint: "vs" },
1211
937
  fragment: {
1212
938
  module: compositeShader,
1213
939
  entryPoint: "fs",
940
+ constants: { APPLY_GAMMA: applyGamma ? 1 : 0 },
1214
941
  targets: [{ format: this.presentationFormat }],
1215
942
  },
1216
943
  primitive: { topology: "triangle-list" },
1217
944
  });
945
+ this.compositePipelineIdentity = makeCompositePipeline(false, "composite pipeline (gamma=1)");
946
+ this.compositePipelineGamma = makeCompositePipeline(true, "composite pipeline (gamma!=1)");
1218
947
  this.bloomPassDescriptor = {
1219
948
  label: "bloom pass",
1220
949
  colorAttachments: [
@@ -1226,47 +955,9 @@ export class Engine {
1226
955
  },
1227
956
  ],
1228
957
  };
1229
- // GPU picking: encode (modelIndex, materialIndex) as color
1230
958
  const pickShaderModule = this.device.createShaderModule({
1231
959
  label: "pick shader",
1232
- code: /* wgsl */ `
1233
- struct CameraUniforms {
1234
- view: mat4x4f,
1235
- projection: mat4x4f,
1236
- viewPos: vec3f,
1237
- _padding: f32,
1238
- };
1239
- struct PickId {
1240
- modelId: f32,
1241
- materialId: f32,
1242
- _p1: f32,
1243
- _p2: f32,
1244
- };
1245
-
1246
- @group(0) @binding(0) var<uniform> camera: CameraUniforms;
1247
- @group(1) @binding(0) var<storage, read> skinMats: array<mat4x4f>;
1248
- @group(2) @binding(0) var<uniform> pickId: PickId;
1249
-
1250
- @vertex fn vs(
1251
- @location(0) position: vec3f,
1252
- @location(1) normal: vec3f,
1253
- @location(2) uv: vec2f,
1254
- @location(3) joints0: vec4<u32>,
1255
- @location(4) weights0: vec4<f32>
1256
- ) -> @builtin(position) vec4f {
1257
- let pos4 = vec4f(position, 1.0);
1258
- let weightSum = weights0.x + weights0.y + weights0.z + weights0.w;
1259
- let invWeightSum = select(1.0, 1.0 / weightSum, weightSum > 0.0001);
1260
- let nw = select(vec4f(1.0, 0.0, 0.0, 0.0), weights0 * invWeightSum, weightSum > 0.0001);
1261
- var sp = vec4f(0.0);
1262
- for (var i = 0u; i < 4u; i++) { sp += (skinMats[joints0[i]] * pos4) * nw[i]; }
1263
- return camera.projection * camera.view * vec4f(sp.xyz, 1.0);
1264
- }
1265
-
1266
- @fragment fn fs() -> @location(0) vec4f {
1267
- return vec4f(pickId.modelId / 255.0, pickId.materialId / 255.0, 0.0, 1.0);
1268
- }
1269
- `,
960
+ code: PICK_SHADER_WGSL,
1270
961
  });
1271
962
  this.pickPerFrameBindGroupLayout = this.device.createBindGroupLayout({
1272
963
  label: "pick per-frame layout",
@@ -1400,19 +1091,23 @@ export class Engine {
1400
1091
  usage: GPUTextureUsage.RENDER_ATTACHMENT,
1401
1092
  });
1402
1093
  const depthTextureView = this.depthTexture.createView();
1094
+ // storeOp="discard" on MSAA views keeps per-sample data in Apple TBDR tile memory —
1095
+ // only the resolveTarget (hdrResolveTexture / maskResolveView) gets written to RAM.
1096
+ // With storeOp="store" Safari's Metal backend spills the full MS buffer every frame
1097
+ // (rgba16f × 4 samples on a 4K canvas ≈ 256 MB/frame of dead bandwidth).
1403
1098
  const colorAttachment = {
1404
1099
  view: this.multisampleTexture.createView(),
1405
1100
  resolveTarget: this.hdrResolveTexture.createView(),
1406
1101
  clearValue: { r: 0, g: 0, b: 0, a: 0 },
1407
1102
  loadOp: "clear",
1408
- storeOp: "store",
1103
+ storeOp: "discard",
1409
1104
  };
1410
1105
  const maskAttachment = {
1411
1106
  view: this.multisampleMaskTexture.createView(),
1412
1107
  resolveTarget: this.maskResolveView,
1413
1108
  clearValue: { r: 0, g: 0, b: 0, a: 0 },
1414
1109
  loadOp: "clear",
1415
- storeOp: "store",
1110
+ storeOp: "discard",
1416
1111
  };
1417
1112
  this.renderPassDescriptor = {
1418
1113
  label: "renderPass",
@@ -1421,7 +1116,8 @@ export class Engine {
1421
1116
  view: depthTextureView,
1422
1117
  depthClearValue: 1.0,
1423
1118
  depthLoadOp: "clear",
1424
- depthStoreOp: "store",
1119
+ // Main-pass depth is not sampled later (shadow uses its own map, composite is depthless).
1120
+ depthStoreOp: "discard",
1425
1121
  stencilClearValue: 0,
1426
1122
  stencilLoadOp: "clear",
1427
1123
  stencilStoreOp: "discard",
@@ -2141,6 +1837,26 @@ export class Engine {
2141
1837
  }
2142
1838
  currentIndexOffset += indexCount;
2143
1839
  }
1840
+ // Sort so the opaque bucket is emitted in the order the stencil-based
1841
+ // see-through-hair effect requires: {non-hair, non-eye} → {eye} → {hair}.
1842
+ // Eye writes stencil=EYE_VALUE; hair's pipeline stencil-tests "not equal" so
1843
+ // it skips eye pixels; a follow-up hairOverEyes pass (see renderOneModel)
1844
+ // re-fills those skipped pixels alpha-blended. Array.sort is stable in
1845
+ // ES2019+, so within a bucket the PMX material order is preserved.
1846
+ const typeOrder = {
1847
+ opaque: 0,
1848
+ "opaque-outline": 1,
1849
+ transparent: 2,
1850
+ "transparent-outline": 3,
1851
+ ground: 4,
1852
+ };
1853
+ const presetRank = (p) => (p === "hair" ? 2 : p === "eye" ? 1 : 0);
1854
+ inst.drawCalls.sort((a, b) => {
1855
+ const ta = typeOrder[a.type] - typeOrder[b.type];
1856
+ if (ta !== 0)
1857
+ return ta;
1858
+ return presetRank(a.preset) - presetRank(b.preset);
1859
+ });
2144
1860
  for (const d of inst.drawCalls) {
2145
1861
  if (d.type === "opaque")
2146
1862
  inst.shadowDrawCalls.push(d);
@@ -2213,20 +1929,7 @@ export class Engine {
2213
1929
  });
2214
1930
  const module = this.device.createShaderModule({
2215
1931
  label: "mipmap blit",
2216
- code: /* wgsl */ `
2217
- @group(0) @binding(0) var src: texture_2d<f32>;
2218
- @group(0) @binding(1) var samp: sampler;
2219
- @vertex fn vs(@builtin(vertex_index) vi: u32) -> @builtin(position) vec4f {
2220
- let x = f32((vi & 1u) << 2u) - 1.0;
2221
- let y = f32((vi & 2u) << 1u) - 1.0;
2222
- return vec4f(x, y, 0.0, 1.0);
2223
- }
2224
- @fragment fn fs(@builtin(position) p: vec4f) -> @location(0) vec4f {
2225
- let dstDims = vec2f(textureDimensions(src)) * 0.5;
2226
- let uv = p.xy / max(dstDims, vec2f(1.0));
2227
- return textureSampleLevel(src, samp, uv, 0.0);
2228
- }
2229
- `,
1932
+ code: MIPMAP_BLIT_SHADER_WGSL,
2230
1933
  });
2231
1934
  this.mipBlitPipeline = this.device.createRenderPipeline({
2232
1935
  label: "mipmap blit pipeline",
@@ -2441,7 +2144,8 @@ export class Engine {
2441
2144
  const compositeAttachment = this.compositePassDescriptor.colorAttachments[0];
2442
2145
  compositeAttachment.view = this.context.getCurrentTexture().createView();
2443
2146
  const cpass = encoder.beginRenderPass(this.compositePassDescriptor);
2444
- cpass.setPipeline(this.compositePipeline);
2147
+ const compositePipeline = this.viewTransform.gamma === 1.0 ? this.compositePipelineIdentity : this.compositePipelineGamma;
2148
+ cpass.setPipeline(compositePipeline);
2445
2149
  cpass.setBindGroup(0, this.compositeBindGroup);
2446
2150
  cpass.draw(3);
2447
2151
  cpass.end();
@@ -2544,11 +2248,36 @@ export class Engine {
2544
2248
  pass.setVertexBuffer(1, inst.jointsBuffer);
2545
2249
  pass.setVertexBuffer(2, inst.weightsBuffer);
2546
2250
  pass.setIndexBuffer(inst.indexBuffer, "uint32");
2251
+ // Single stencil-reference set covers eye (write), hair (read not-equal),
2252
+ // and hairOverEyes (read equal). Non-stencil pipelines ignore the value.
2253
+ pass.setStencilReference(Engine.STENCIL_EYE_VALUE);
2547
2254
  this.drawMaterials(pass, inst, "opaque");
2548
2255
  this.drawOutlines(pass, inst, "opaque-outline");
2256
+ this.drawHairOverEyes(pass, inst);
2549
2257
  this.drawMaterials(pass, inst, "transparent");
2550
2258
  this.drawOutlines(pass, inst, "transparent-outline");
2551
2259
  }
2260
+ /**
2261
+ * Second hair pass for the see-through-hair effect. Re-draws every hair opaque
2262
+ * draw using `hairOverEyesPipeline` — which stencil-matches `EYE_VALUE` and runs
2263
+ * the hair shader with `IS_OVER_EYES=true` so alpha is halved. depthWriteEnabled
2264
+ * is off, so the eye's depth stays authoritative for anything drawn after.
2265
+ */
2266
+ drawHairOverEyes(pass, inst) {
2267
+ let bound = false;
2268
+ for (const draw of inst.drawCalls) {
2269
+ if (draw.type !== "opaque" || draw.preset !== "hair" || !this.shouldRenderDrawCall(inst, draw))
2270
+ continue;
2271
+ if (!bound) {
2272
+ pass.setPipeline(this.hairOverEyesPipeline);
2273
+ pass.setBindGroup(0, this.perFrameBindGroup);
2274
+ pass.setBindGroup(1, inst.mainPerInstanceBindGroup);
2275
+ bound = true;
2276
+ }
2277
+ pass.setBindGroup(2, draw.bindGroup);
2278
+ pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0);
2279
+ }
2280
+ }
2552
2281
  updateCameraUniforms() {
2553
2282
  const viewMatrix = this.camera.getViewMatrix();
2554
2283
  const projectionMatrix = this.camera.getProjectionMatrix();
@@ -2592,6 +2321,9 @@ export class Engine {
2592
2321
  Engine.instance = null;
2593
2322
  Engine.MULTISAMPLE_COUNT = 4;
2594
2323
  Engine.HDR_FORMAT = "rgba16float";
2324
+ /** Stencil value stamped by eye draws so hair can stencil-test against it and
2325
+ * alpha-blend a second pass over eye silhouette pixels (see-through-hair effect). */
2326
+ Engine.STENCIL_EYE_VALUE = 1;
2595
2327
  /** Single-channel mask written alongside HDR color — 1 = model geometry (contributes
2596
2328
  * to bloom), 0 = ground (never blooms). Sampled by the bloom blit pass to gate the
2597
2329
  * prefilter so ground brightness can't halo the scene. */