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.
- package/README.md +59 -39
- package/dist/engine.d.ts +15 -2
- package/dist/engine.d.ts.map +1 -1
- package/dist/engine.js +159 -427
- package/dist/index.d.ts +1 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/shaders/body.d.ts +1 -1
- package/dist/shaders/body.d.ts.map +1 -1
- package/dist/shaders/body.js +7 -28
- package/dist/shaders/cloth_rough.d.ts +1 -1
- package/dist/shaders/cloth_rough.d.ts.map +1 -1
- package/dist/shaders/cloth_rough.js +4 -16
- package/dist/shaders/cloth_smooth.d.ts +1 -1
- package/dist/shaders/cloth_smooth.d.ts.map +1 -1
- package/dist/shaders/cloth_smooth.js +5 -17
- package/dist/shaders/default.d.ts +1 -1
- package/dist/shaders/default.d.ts.map +1 -1
- package/dist/shaders/eye.d.ts +1 -1
- package/dist/shaders/eye.d.ts.map +1 -1
- package/dist/shaders/face.d.ts +1 -1
- package/dist/shaders/face.d.ts.map +1 -1
- package/dist/shaders/face.js +21 -57
- package/dist/shaders/hair.d.ts +1 -1
- package/dist/shaders/hair.d.ts.map +1 -1
- package/dist/shaders/hair.js +7 -27
- package/dist/shaders/materials/body.d.ts +2 -0
- package/dist/shaders/materials/body.d.ts.map +1 -0
- package/dist/shaders/materials/body.js +199 -0
- package/dist/shaders/materials/cloth_rough.d.ts +2 -0
- package/dist/shaders/materials/cloth_rough.d.ts.map +1 -0
- package/dist/shaders/materials/cloth_rough.js +178 -0
- package/dist/shaders/materials/cloth_smooth.d.ts +2 -0
- package/dist/shaders/materials/cloth_smooth.d.ts.map +1 -0
- package/dist/shaders/materials/cloth_smooth.js +174 -0
- package/dist/shaders/materials/default.d.ts +2 -0
- package/dist/shaders/materials/default.d.ts.map +1 -0
- package/dist/shaders/materials/default.js +171 -0
- package/dist/shaders/materials/eye.d.ts +2 -0
- package/dist/shaders/materials/eye.d.ts.map +1 -0
- package/dist/shaders/materials/eye.js +146 -0
- package/dist/shaders/materials/face.d.ts +2 -0
- package/dist/shaders/materials/face.d.ts.map +1 -0
- package/dist/shaders/materials/face.js +199 -0
- package/dist/shaders/materials/hair.d.ts +2 -0
- package/dist/shaders/materials/hair.d.ts.map +1 -0
- package/dist/shaders/materials/hair.js +185 -0
- package/dist/shaders/materials/metal.d.ts +2 -0
- package/dist/shaders/materials/metal.d.ts.map +1 -0
- package/dist/shaders/materials/metal.js +183 -0
- package/dist/shaders/materials/nodes.d.ts +2 -0
- package/dist/shaders/materials/nodes.d.ts.map +1 -0
- package/{src/shaders/nodes.ts → dist/shaders/materials/nodes.js} +32 -16
- package/dist/shaders/materials/stockings.d.ts +2 -0
- package/dist/shaders/materials/stockings.d.ts.map +1 -0
- package/dist/shaders/materials/stockings.js +244 -0
- package/dist/shaders/metal.d.ts +1 -1
- package/dist/shaders/metal.d.ts.map +1 -1
- package/dist/shaders/metal.js +4 -17
- package/dist/shaders/nodes.d.ts +1 -1
- package/dist/shaders/nodes.d.ts.map +1 -1
- package/dist/shaders/nodes.js +0 -9
- package/dist/shaders/passes/bloom.d.ts +4 -0
- package/dist/shaders/passes/bloom.d.ts.map +1 -0
- package/dist/shaders/passes/bloom.js +117 -0
- package/dist/shaders/passes/composite.d.ts +2 -0
- package/dist/shaders/passes/composite.d.ts.map +1 -0
- package/dist/shaders/passes/composite.js +61 -0
- package/dist/shaders/passes/ground.d.ts +2 -0
- package/dist/shaders/passes/ground.d.ts.map +1 -0
- package/dist/shaders/passes/ground.js +93 -0
- package/dist/shaders/passes/mipmap.d.ts +2 -0
- package/dist/shaders/passes/mipmap.d.ts.map +1 -0
- package/dist/shaders/passes/mipmap.js +16 -0
- package/dist/shaders/passes/outline.d.ts +2 -0
- package/dist/shaders/passes/outline.d.ts.map +1 -0
- package/dist/shaders/passes/outline.js +83 -0
- package/dist/shaders/passes/pick.d.ts +2 -0
- package/dist/shaders/passes/pick.d.ts.map +1 -0
- package/dist/shaders/passes/pick.js +39 -0
- package/dist/shaders/passes/shadow.d.ts +2 -0
- package/dist/shaders/passes/shadow.d.ts.map +1 -0
- package/dist/shaders/passes/shadow.js +16 -0
- package/dist/shaders/stockings.d.ts +1 -1
- package/dist/shaders/stockings.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/engine.ts +197 -439
- package/src/index.ts +3 -2
- package/src/shaders/{body.ts → materials/body.ts} +7 -28
- package/src/shaders/{cloth_rough.ts → materials/cloth_rough.ts} +4 -16
- package/src/shaders/{cloth_smooth.ts → materials/cloth_smooth.ts} +5 -17
- package/src/shaders/{face.ts → materials/face.ts} +21 -57
- package/src/shaders/{hair.ts → materials/hair.ts} +17 -28
- package/src/shaders/{metal.ts → materials/metal.ts} +15 -19
- package/src/shaders/materials/nodes.ts +483 -0
- package/src/shaders/passes/bloom.ts +121 -0
- package/src/shaders/passes/composite.ts +62 -0
- package/src/shaders/passes/ground.ts +94 -0
- package/src/shaders/passes/mipmap.ts +17 -0
- package/src/shaders/passes/outline.ts +84 -0
- package/src/shaders/passes/pick.ts +40 -0
- package/src/shaders/passes/shadow.ts +17 -0
- package/src/shaders/classify.ts +0 -25
- /package/src/shaders/{default.ts → materials/default.ts} +0 -0
- /package/src/shaders/{eye.ts → materials/eye.ts} +0 -0
- /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 {
|
|
20
|
-
import {
|
|
21
|
-
import {
|
|
22
|
-
import {
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
import {
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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: "
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
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:
|
|
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: "
|
|
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: "
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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()
|