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/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 {
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
12
|
-
import {
|
|
13
|
-
import {
|
|
14
|
-
import {
|
|
15
|
-
import {
|
|
16
|
-
|
|
17
|
-
|
|
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,
|
|
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
|
-
|
|
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: "
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
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
|
-
|
|
1208
|
-
label
|
|
1209
|
-
layout:
|
|
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:
|
|
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: "
|
|
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: "
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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. */
|