reze-engine 0.11.2 → 0.11.3

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 (104) hide show
  1. package/dist/engine.d.ts +4 -2
  2. package/dist/engine.d.ts.map +1 -1
  3. package/dist/engine.js +58 -426
  4. package/dist/index.d.ts +1 -2
  5. package/dist/index.d.ts.map +1 -1
  6. package/dist/shaders/body.d.ts +1 -1
  7. package/dist/shaders/body.d.ts.map +1 -1
  8. package/dist/shaders/body.js +7 -28
  9. package/dist/shaders/cloth_rough.d.ts +1 -1
  10. package/dist/shaders/cloth_rough.d.ts.map +1 -1
  11. package/dist/shaders/cloth_rough.js +4 -16
  12. package/dist/shaders/cloth_smooth.d.ts +1 -1
  13. package/dist/shaders/cloth_smooth.d.ts.map +1 -1
  14. package/dist/shaders/cloth_smooth.js +5 -17
  15. package/dist/shaders/default.d.ts +1 -1
  16. package/dist/shaders/default.d.ts.map +1 -1
  17. package/dist/shaders/eye.d.ts +1 -1
  18. package/dist/shaders/eye.d.ts.map +1 -1
  19. package/dist/shaders/face.d.ts +1 -1
  20. package/dist/shaders/face.d.ts.map +1 -1
  21. package/dist/shaders/face.js +21 -57
  22. package/dist/shaders/hair.d.ts +1 -1
  23. package/dist/shaders/hair.d.ts.map +1 -1
  24. package/dist/shaders/hair.js +7 -27
  25. package/dist/shaders/materials/body.d.ts +2 -0
  26. package/dist/shaders/materials/body.d.ts.map +1 -0
  27. package/dist/shaders/materials/body.js +199 -0
  28. package/dist/shaders/materials/cloth_rough.d.ts +2 -0
  29. package/dist/shaders/materials/cloth_rough.d.ts.map +1 -0
  30. package/dist/shaders/materials/cloth_rough.js +178 -0
  31. package/dist/shaders/materials/cloth_smooth.d.ts +2 -0
  32. package/dist/shaders/materials/cloth_smooth.d.ts.map +1 -0
  33. package/dist/shaders/materials/cloth_smooth.js +174 -0
  34. package/dist/shaders/materials/default.d.ts +2 -0
  35. package/dist/shaders/materials/default.d.ts.map +1 -0
  36. package/dist/shaders/materials/default.js +171 -0
  37. package/dist/shaders/materials/eye.d.ts +2 -0
  38. package/dist/shaders/materials/eye.d.ts.map +1 -0
  39. package/dist/shaders/materials/eye.js +146 -0
  40. package/dist/shaders/materials/face.d.ts +2 -0
  41. package/dist/shaders/materials/face.d.ts.map +1 -0
  42. package/dist/shaders/materials/face.js +199 -0
  43. package/dist/shaders/materials/hair.d.ts +2 -0
  44. package/dist/shaders/materials/hair.d.ts.map +1 -0
  45. package/dist/shaders/materials/hair.js +176 -0
  46. package/dist/shaders/materials/metal.d.ts +2 -0
  47. package/dist/shaders/materials/metal.d.ts.map +1 -0
  48. package/dist/shaders/materials/metal.js +183 -0
  49. package/dist/shaders/materials/nodes.d.ts +2 -0
  50. package/dist/shaders/materials/nodes.d.ts.map +1 -0
  51. package/{src/shaders/nodes.ts → dist/shaders/materials/nodes.js} +32 -16
  52. package/dist/shaders/materials/stockings.d.ts +2 -0
  53. package/dist/shaders/materials/stockings.d.ts.map +1 -0
  54. package/dist/shaders/materials/stockings.js +244 -0
  55. package/dist/shaders/metal.d.ts +1 -1
  56. package/dist/shaders/metal.d.ts.map +1 -1
  57. package/dist/shaders/metal.js +4 -17
  58. package/dist/shaders/nodes.d.ts +1 -1
  59. package/dist/shaders/nodes.d.ts.map +1 -1
  60. package/dist/shaders/nodes.js +0 -9
  61. package/dist/shaders/passes/bloom.d.ts +4 -0
  62. package/dist/shaders/passes/bloom.d.ts.map +1 -0
  63. package/dist/shaders/passes/bloom.js +117 -0
  64. package/dist/shaders/passes/composite.d.ts +2 -0
  65. package/dist/shaders/passes/composite.d.ts.map +1 -0
  66. package/dist/shaders/passes/composite.js +61 -0
  67. package/dist/shaders/passes/ground.d.ts +2 -0
  68. package/dist/shaders/passes/ground.d.ts.map +1 -0
  69. package/dist/shaders/passes/ground.js +93 -0
  70. package/dist/shaders/passes/mipmap.d.ts +2 -0
  71. package/dist/shaders/passes/mipmap.d.ts.map +1 -0
  72. package/dist/shaders/passes/mipmap.js +16 -0
  73. package/dist/shaders/passes/outline.d.ts +2 -0
  74. package/dist/shaders/passes/outline.d.ts.map +1 -0
  75. package/dist/shaders/passes/outline.js +83 -0
  76. package/dist/shaders/passes/pick.d.ts +2 -0
  77. package/dist/shaders/passes/pick.d.ts.map +1 -0
  78. package/dist/shaders/passes/pick.js +39 -0
  79. package/dist/shaders/passes/shadow.d.ts +2 -0
  80. package/dist/shaders/passes/shadow.d.ts.map +1 -0
  81. package/dist/shaders/passes/shadow.js +16 -0
  82. package/dist/shaders/stockings.d.ts +1 -1
  83. package/dist/shaders/stockings.d.ts.map +1 -1
  84. package/package.json +1 -1
  85. package/src/engine.ts +93 -438
  86. package/src/index.ts +3 -2
  87. package/src/shaders/{body.ts → materials/body.ts} +7 -28
  88. package/src/shaders/{cloth_rough.ts → materials/cloth_rough.ts} +4 -16
  89. package/src/shaders/{cloth_smooth.ts → materials/cloth_smooth.ts} +5 -17
  90. package/src/shaders/{face.ts → materials/face.ts} +21 -57
  91. package/src/shaders/{hair.ts → materials/hair.ts} +7 -27
  92. package/src/shaders/{metal.ts → materials/metal.ts} +15 -19
  93. package/src/shaders/materials/nodes.ts +483 -0
  94. package/src/shaders/passes/bloom.ts +121 -0
  95. package/src/shaders/passes/composite.ts +62 -0
  96. package/src/shaders/passes/ground.ts +94 -0
  97. package/src/shaders/passes/mipmap.ts +17 -0
  98. package/src/shaders/passes/outline.ts +84 -0
  99. package/src/shaders/passes/pick.ts +40 -0
  100. package/src/shaders/passes/shadow.ts +17 -0
  101. package/src/shaders/classify.ts +0 -25
  102. /package/src/shaders/{default.ts → materials/default.ts} +0 -0
  103. /package/src/shaders/{eye.ts → materials/eye.ts} +0 -0
  104. /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
 
@@ -223,11 +256,15 @@ export class Engine {
223
256
  private maskResolveView!: GPUTextureView
224
257
  private renderPassDescriptor!: GPURenderPassDescriptor
225
258
  private compositePassDescriptor!: GPURenderPassDescriptor
226
- private compositePipeline!: GPURenderPipeline
259
+ // Two specialized composite pipelines via WGSL pipeline-override constants.
260
+ // Identity variant skips the gamma pow entirely at shader-compile time —
261
+ // Safari's Metal backend won't fold pow(x, 1) to identity.
262
+ private compositePipelineIdentity!: GPURenderPipeline
263
+ private compositePipelineGamma!: GPURenderPipeline
227
264
  private compositeBindGroupLayout!: GPUBindGroupLayout
228
265
  private compositeBindGroup!: GPUBindGroup
229
266
  private compositeUniformBuffer!: GPUBuffer
230
- // [exposure, gamma, _, _, bloomTint.x, bloomTint.y, bloomTint.z, bloomIntensity]
267
+ // [exposure, invGamma, _, _, bloomTint.x, bloomTint.y, bloomTint.z, bloomIntensity]
231
268
  private readonly compositeUniformData = new Float32Array(8)
232
269
 
233
270
  // EEVEE-style bloom pyramid (mirrors Blender 3.6 effect_bloom_frag.glsl):
@@ -403,7 +440,10 @@ export class Engine {
403
440
  const effIntensity = b.enabled ? b.intensity : 0.0
404
441
  const u = this.compositeUniformData
405
442
  u[0] = v.exposure
406
- u[1] = Math.max(v.gamma, 1e-4)
443
+ // Store 1/gamma so the shader avoids a per-pixel divide. Safari's Metal
444
+ // compiler doesn't fold `pow(x, 1/g)` into identity when g=1, so also emit
445
+ // a uniform branch that skips the pow entirely in the common case.
446
+ u[1] = 1.0 / Math.max(v.gamma, 1e-4)
407
447
  u[2] = 0.0
408
448
  u[3] = 0.0
409
449
  u[4] = b.color.x
@@ -893,21 +933,7 @@ export class Engine {
893
933
  })
894
934
  const shadowShader = this.device.createShaderModule({
895
935
  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
- `,
936
+ code: SHADOW_DEPTH_SHADER_WGSL,
911
937
  })
912
938
  this.shadowDepthPipeline = this.device.createRenderPipeline({
913
939
  label: "shadow depth pipeline",
@@ -967,99 +993,7 @@ export class Engine {
967
993
  })
968
994
  const groundShadowShader = this.device.createShaderModule({
969
995
  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
- `,
996
+ code: GROUND_SHADOW_SHADER_WGSL,
1063
997
  })
1064
998
  this.groundShadowPipeline = this.createRenderPipeline({
1065
999
  label: "ground shadow pipeline",
@@ -1103,90 +1037,7 @@ export class Engine {
1103
1037
 
1104
1038
  const outlineShaderModule = this.device.createShaderModule({
1105
1039
  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
- `,
1040
+ code: OUTLINE_SHADER_WGSL,
1190
1041
  })
1191
1042
 
1192
1043
  this.outlinePipeline = this.createRenderPipeline({
@@ -1251,130 +1102,19 @@ export class Engine {
1251
1102
  ],
1252
1103
  })
1253
1104
 
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
1105
  const bloomBlitShader = this.device.createShaderModule({
1264
1106
  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
- `,
1107
+ code: BLOOM_BLIT_SHADER_WGSL,
1308
1108
  })
1309
1109
 
1310
- // Downsample: Jimenez/COD 13-tap dual-box — 5 weighted 2×2 averages, rejects nyquist ringing.
1311
1110
  const bloomDownsampleShader = this.device.createShaderModule({
1312
1111
  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
- `,
1112
+ code: BLOOM_DOWNSAMPLE_SHADER_WGSL,
1348
1113
  })
1349
1114
 
1350
- // Upsample: 9-tap tent, progressively added to matching downsample mip. Blender radius = sample scale.
1351
1115
  const bloomUpsampleShader = this.device.createShaderModule({
1352
1116
  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
- `,
1117
+ code: BLOOM_UPSAMPLE_SHADER_WGSL,
1378
1118
  })
1379
1119
 
1380
1120
  const bloomBlitLayout = this.device.createPipelineLayout({ bindGroupLayouts: [this.bloomBlitBindGroupLayout] })
@@ -1425,68 +1165,27 @@ export class Engine {
1425
1165
 
1426
1166
  const compositeShader = this.device.createShaderModule({
1427
1167
  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
- })
1168
+ code: COMPOSITE_SHADER_WGSL,
1169
+ })
1170
+
1171
+ const compositePipelineLayout = this.device.createPipelineLayout({
1172
+ bindGroupLayouts: [this.compositeBindGroupLayout],
1173
+ })
1174
+ const makeCompositePipeline = (applyGamma: boolean, label: string): GPURenderPipeline =>
1175
+ this.device.createRenderPipeline({
1176
+ label,
1177
+ layout: compositePipelineLayout,
1178
+ vertex: { module: compositeShader, entryPoint: "vs" },
1179
+ fragment: {
1180
+ module: compositeShader,
1181
+ entryPoint: "fs",
1182
+ constants: { APPLY_GAMMA: applyGamma ? 1 : 0 },
1183
+ targets: [{ format: this.presentationFormat }],
1184
+ },
1185
+ primitive: { topology: "triangle-list" },
1186
+ })
1187
+ this.compositePipelineIdentity = makeCompositePipeline(false, "composite pipeline (gamma=1)")
1188
+ this.compositePipelineGamma = makeCompositePipeline(true, "composite pipeline (gamma!=1)")
1490
1189
 
1491
1190
  this.bloomPassDescriptor = {
1492
1191
  label: "bloom pass",
@@ -1500,47 +1199,9 @@ export class Engine {
1500
1199
  ],
1501
1200
  } as GPURenderPassDescriptor
1502
1201
 
1503
- // GPU picking: encode (modelIndex, materialIndex) as color
1504
1202
  const pickShaderModule = this.device.createShaderModule({
1505
1203
  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
- `,
1204
+ code: PICK_SHADER_WGSL,
1544
1205
  })
1545
1206
 
1546
1207
  this.pickPerFrameBindGroupLayout = this.device.createBindGroupLayout({
@@ -1691,12 +1352,16 @@ export class Engine {
1691
1352
 
1692
1353
  const depthTextureView = this.depthTexture.createView()
1693
1354
 
1355
+ // storeOp="discard" on MSAA views keeps per-sample data in Apple TBDR tile memory —
1356
+ // only the resolveTarget (hdrResolveTexture / maskResolveView) gets written to RAM.
1357
+ // With storeOp="store" Safari's Metal backend spills the full MS buffer every frame
1358
+ // (rgba16f × 4 samples on a 4K canvas ≈ 256 MB/frame of dead bandwidth).
1694
1359
  const colorAttachment: GPURenderPassColorAttachment = {
1695
1360
  view: this.multisampleTexture.createView(),
1696
1361
  resolveTarget: this.hdrResolveTexture.createView(),
1697
1362
  clearValue: { r: 0, g: 0, b: 0, a: 0 },
1698
1363
  loadOp: "clear",
1699
- storeOp: "store",
1364
+ storeOp: "discard",
1700
1365
  }
1701
1366
 
1702
1367
  const maskAttachment: GPURenderPassColorAttachment = {
@@ -1704,7 +1369,7 @@ export class Engine {
1704
1369
  resolveTarget: this.maskResolveView,
1705
1370
  clearValue: { r: 0, g: 0, b: 0, a: 0 },
1706
1371
  loadOp: "clear",
1707
- storeOp: "store",
1372
+ storeOp: "discard",
1708
1373
  }
1709
1374
 
1710
1375
  this.renderPassDescriptor = {
@@ -1714,7 +1379,8 @@ export class Engine {
1714
1379
  view: depthTextureView,
1715
1380
  depthClearValue: 1.0,
1716
1381
  depthLoadOp: "clear",
1717
- depthStoreOp: "store",
1382
+ // Main-pass depth is not sampled later (shadow uses its own map, composite is depthless).
1383
+ depthStoreOp: "discard",
1718
1384
  stencilClearValue: 0,
1719
1385
  stencilLoadOp: "clear",
1720
1386
  stencilStoreOp: "discard",
@@ -2635,20 +2301,7 @@ export class Engine {
2635
2301
  })
2636
2302
  const module = this.device.createShaderModule({
2637
2303
  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
- `,
2304
+ code: MIPMAP_BLIT_SHADER_WGSL,
2652
2305
  })
2653
2306
  this.mipBlitPipeline = this.device.createRenderPipeline({
2654
2307
  label: "mipmap blit pipeline",
@@ -2919,7 +2572,9 @@ export class Engine {
2919
2572
  const compositeAttachment = (this.compositePassDescriptor.colorAttachments as GPURenderPassColorAttachment[])[0]
2920
2573
  compositeAttachment.view = this.context.getCurrentTexture().createView()
2921
2574
  const cpass = encoder.beginRenderPass(this.compositePassDescriptor)
2922
- cpass.setPipeline(this.compositePipeline)
2575
+ const compositePipeline =
2576
+ this.viewTransform.gamma === 1.0 ? this.compositePipelineIdentity : this.compositePipelineGamma
2577
+ cpass.setPipeline(compositePipeline)
2923
2578
  cpass.setBindGroup(0, this.compositeBindGroup)
2924
2579
  cpass.draw(3)
2925
2580
  cpass.end()
package/src/index.ts CHANGED
@@ -7,6 +7,8 @@ export {
7
7
  type BloomOptions,
8
8
  type ViewTransformOptions,
9
9
  type LoadModelFromFilesOptions,
10
+ type MaterialPreset,
11
+ type MaterialPresetMap,
10
12
  } from "./engine"
11
13
  export { parsePmxFolderInput, pmxFileAtRelativePath, type PmxFolderInputResult } from "./folder-upload"
12
14
  export { Model } from "./model"
@@ -21,5 +23,4 @@ export type {
21
23
  ControlPoint,
22
24
  } from "./animation"
23
25
  export { FPS } from "./animation"
24
- export { Physics, type PhysicsOptions } from "./physics"
25
- export type { MaterialPreset, MaterialPresetMap } from "./shaders/classify"
26
+ export { Physics, type PhysicsOptions } from "./physics"