reze-engine 0.10.1 → 0.11.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 +113 -20
- package/dist/asset-reader.d.ts +16 -0
- package/dist/asset-reader.d.ts.map +1 -0
- package/dist/asset-reader.js +74 -0
- package/dist/engine.d.ts +179 -36
- package/dist/engine.d.ts.map +1 -1
- package/dist/engine.js +1133 -321
- package/dist/folder-upload.d.ts +24 -0
- package/dist/folder-upload.d.ts.map +1 -0
- package/dist/folder-upload.js +50 -0
- package/dist/index.d.ts +3 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -2
- package/dist/model.d.ts +6 -1
- package/dist/model.d.ts.map +1 -1
- package/dist/model.js +34 -2
- package/dist/pmx-loader.d.ts +3 -0
- package/dist/pmx-loader.d.ts.map +1 -1
- package/dist/pmx-loader.js +9 -2
- package/dist/shaders/body.d.ts +2 -0
- package/dist/shaders/body.d.ts.map +1 -0
- package/dist/shaders/body.js +209 -0
- package/dist/shaders/classify.d.ts +4 -0
- package/dist/shaders/classify.d.ts.map +1 -0
- package/dist/shaders/classify.js +12 -0
- package/dist/shaders/cloth_rough.d.ts +2 -0
- package/dist/shaders/cloth_rough.d.ts.map +1 -0
- package/dist/shaders/cloth_rough.js +172 -0
- package/dist/shaders/cloth_smooth.d.ts +2 -0
- package/dist/shaders/cloth_smooth.d.ts.map +1 -0
- package/dist/shaders/cloth_smooth.js +171 -0
- package/dist/shaders/default.d.ts +2 -0
- package/dist/shaders/default.d.ts.map +1 -0
- package/dist/shaders/default.js +168 -0
- package/dist/shaders/dfg_lut.d.ts +4 -0
- package/dist/shaders/dfg_lut.d.ts.map +1 -0
- package/dist/shaders/dfg_lut.js +125 -0
- package/dist/shaders/eye.d.ts +2 -0
- package/dist/shaders/eye.d.ts.map +1 -0
- package/dist/shaders/eye.js +142 -0
- package/dist/shaders/face.d.ts +2 -0
- package/dist/shaders/face.d.ts.map +1 -0
- package/dist/shaders/face.js +211 -0
- package/dist/shaders/hair.d.ts +2 -0
- package/dist/shaders/hair.d.ts.map +1 -0
- package/dist/shaders/hair.js +186 -0
- package/dist/shaders/ltc_mag_lut.d.ts +3 -0
- package/dist/shaders/ltc_mag_lut.d.ts.map +1 -0
- package/dist/shaders/ltc_mag_lut.js +1033 -0
- package/dist/shaders/metal.d.ts +2 -0
- package/dist/shaders/metal.d.ts.map +1 -0
- package/dist/shaders/metal.js +171 -0
- package/dist/shaders/nodes.d.ts +2 -0
- package/dist/shaders/nodes.d.ts.map +1 -0
- package/dist/shaders/nodes.js +423 -0
- package/dist/shaders/stockings.d.ts +2 -0
- package/dist/shaders/stockings.d.ts.map +1 -0
- package/dist/shaders/stockings.js +229 -0
- package/package.json +1 -1
- package/src/asset-reader.ts +79 -0
- package/src/engine.ts +1352 -383
- package/src/folder-upload.ts +59 -0
- package/src/index.ts +12 -2
- package/src/model.ts +34 -2
- package/src/pmx-loader.ts +11 -2
- package/src/shaders/body.ts +211 -0
- package/src/shaders/classify.ts +25 -0
- package/src/shaders/cloth_rough.ts +174 -0
- package/src/shaders/cloth_smooth.ts +173 -0
- package/src/shaders/default.ts +169 -0
- package/src/shaders/dfg_lut.ts +127 -0
- package/src/shaders/eye.ts +143 -0
- package/src/shaders/face.ts +213 -0
- package/src/shaders/hair.ts +188 -0
- package/src/shaders/ltc_mag_lut.ts +1035 -0
- package/src/shaders/metal.ts +173 -0
- package/src/shaders/nodes.ts +424 -0
- package/src/shaders/stockings.ts +231 -0
package/dist/engine.js
CHANGED
|
@@ -2,17 +2,39 @@ import { Camera } from "./camera";
|
|
|
2
2
|
import { Mat4, Vec3 } from "./math";
|
|
3
3
|
import { PmxLoader } from "./pmx-loader";
|
|
4
4
|
import { Physics } from "./physics";
|
|
5
|
+
import { createFetchAssetReader, createFileMapAssetReader, deriveBasePathFromPmxPath, fileListToMap, findFirstPmxFileInList, joinAssetPath, normalizeAssetPath, } from "./asset-reader";
|
|
6
|
+
import { DEFAULT_SHADER_WGSL } from "./shaders/default";
|
|
7
|
+
import { DFG_LUT_SIZE, DFG_LUT_WGSL } from "./shaders/dfg_lut";
|
|
8
|
+
import { LTC_MAG_LUT_SIZE, LTC_MAG_LUT_DATA } from "./shaders/ltc_mag_lut";
|
|
9
|
+
import { FACE_SHADER_WGSL } from "./shaders/face";
|
|
10
|
+
import { HAIR_SHADER_WGSL } from "./shaders/hair";
|
|
11
|
+
import { CLOTH_SMOOTH_SHADER_WGSL } from "./shaders/cloth_smooth";
|
|
12
|
+
import { CLOTH_ROUGH_SHADER_WGSL } from "./shaders/cloth_rough";
|
|
13
|
+
import { METAL_SHADER_WGSL } from "./shaders/metal";
|
|
14
|
+
import { BODY_SHADER_WGSL } from "./shaders/body";
|
|
15
|
+
import { EYE_SHADER_WGSL } from "./shaders/eye";
|
|
16
|
+
import { STOCKINGS_SHADER_WGSL } from "./shaders/stockings";
|
|
17
|
+
import { resolvePreset } from "./shaders/classify";
|
|
18
|
+
export const DEFAULT_BLOOM_OPTIONS = {
|
|
19
|
+
enabled: true,
|
|
20
|
+
threshold: 0.5,
|
|
21
|
+
knee: 0.5,
|
|
22
|
+
radius: 4.0,
|
|
23
|
+
color: new Vec3(1.0, 0.7247558832168579, 0.6487361788749695),
|
|
24
|
+
intensity: 0.03,
|
|
25
|
+
clamp: 0.0,
|
|
26
|
+
};
|
|
27
|
+
export const DEFAULT_VIEW_TRANSFORM = {
|
|
28
|
+
exposure: -0.30000001192092896,
|
|
29
|
+
gamma: 1.0,
|
|
30
|
+
look: "medium_high_contrast",
|
|
31
|
+
};
|
|
5
32
|
export const DEFAULT_ENGINE_OPTIONS = {
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
rimLightIntensity: 0.4,
|
|
10
|
-
cameraDistance: 26.6,
|
|
11
|
-
cameraTarget: new Vec3(0, 12.5, 0),
|
|
12
|
-
cameraFov: Math.PI / 4,
|
|
33
|
+
world: { color: new Vec3(0.4014, 0.4944, 0.647), strength: 0.3 },
|
|
34
|
+
sun: { color: new Vec3(1.0, 1.0, 1.0), strength: 2.0, direction: new Vec3(-0.0873, -0.3844, 0.919) },
|
|
35
|
+
camera: { distance: 26.6, target: new Vec3(0, 12.5, 0), fov: Math.PI / 4 },
|
|
13
36
|
onRaycast: undefined,
|
|
14
37
|
physicsOptions: { constraintSolverKeywords: ["胸"] },
|
|
15
|
-
shadowLightDirection: new Vec3(0.12, -1, 0.16),
|
|
16
38
|
};
|
|
17
39
|
export class Engine {
|
|
18
40
|
static getInstance() {
|
|
@@ -26,11 +48,19 @@ export class Engine {
|
|
|
26
48
|
this.lightData = new Float32Array(64);
|
|
27
49
|
this.lightCount = 0;
|
|
28
50
|
this.resizeObserver = null;
|
|
51
|
+
// [exposure, gamma, _, _, bloomTint.x, bloomTint.y, bloomTint.z, bloomIntensity]
|
|
52
|
+
this.compositeUniformData = new Float32Array(8);
|
|
53
|
+
this.bloomBlitUniformData = new Float32Array(4);
|
|
54
|
+
this.bloomUpsampleUniformData = new Float32Array(4);
|
|
55
|
+
this.bloomMipCount = 0;
|
|
56
|
+
this.bloomDownMipViews = [];
|
|
57
|
+
this.bloomUpMipViews = [];
|
|
58
|
+
this.bloomDownsampleBindGroups = [];
|
|
59
|
+
this.bloomUpsampleBindGroups = [];
|
|
29
60
|
this.hasGround = false;
|
|
30
61
|
this.shadowLightVPMatrix = new Float32Array(16);
|
|
31
62
|
this.groundDrawCall = null;
|
|
32
63
|
this.physicsOptions = DEFAULT_ENGINE_OPTIONS.physicsOptions;
|
|
33
|
-
this.shadowLightDirection = DEFAULT_ENGINE_OPTIONS.shadowLightDirection;
|
|
34
64
|
this.lastTouchTime = 0;
|
|
35
65
|
this.DOUBLE_TAP_DELAY = 300;
|
|
36
66
|
this.pendingPick = null;
|
|
@@ -55,7 +85,7 @@ export class Engine {
|
|
|
55
85
|
};
|
|
56
86
|
this.animationFrameId = null;
|
|
57
87
|
this.renderLoopCallback = null;
|
|
58
|
-
// Shadow
|
|
88
|
+
// Shadow is cast from the visible sun direction — same vector the shader lights with.
|
|
59
89
|
this.shadowLightVPDirty = true;
|
|
60
90
|
this.handleCanvasDoubleClick = (event) => {
|
|
61
91
|
if (!this.onRaycast || this.modelInstances.size === 0)
|
|
@@ -89,20 +119,135 @@ export class Engine {
|
|
|
89
119
|
}
|
|
90
120
|
};
|
|
91
121
|
this.canvas = canvas;
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
122
|
+
const d = DEFAULT_ENGINE_OPTIONS;
|
|
123
|
+
this.world = {
|
|
124
|
+
color: options?.world?.color ?? d.world.color,
|
|
125
|
+
strength: options?.world?.strength ?? d.world.strength,
|
|
126
|
+
};
|
|
127
|
+
this.sun = {
|
|
128
|
+
color: options?.sun?.color ?? d.sun.color,
|
|
129
|
+
strength: options?.sun?.strength ?? d.sun.strength,
|
|
130
|
+
direction: options?.sun?.direction ?? d.sun.direction,
|
|
131
|
+
};
|
|
132
|
+
this.cameraConfig = {
|
|
133
|
+
distance: options?.camera?.distance ?? d.camera.distance,
|
|
134
|
+
target: options?.camera?.target ?? d.camera.target,
|
|
135
|
+
fov: options?.camera?.fov ?? d.camera.fov,
|
|
136
|
+
};
|
|
137
|
+
this.onRaycast = options?.onRaycast;
|
|
138
|
+
this.physicsOptions = options?.physicsOptions ?? d.physicsOptions;
|
|
139
|
+
this.bloomSettings = Engine.mergeBloomDefaults(options?.bloom);
|
|
140
|
+
this.viewTransform = Engine.mergeViewTransformDefaults(options?.view);
|
|
141
|
+
}
|
|
142
|
+
/** Merge partial bloom with EEVEE defaults (same as constructor). */
|
|
143
|
+
static mergeBloomDefaults(partial) {
|
|
144
|
+
const d = DEFAULT_BLOOM_OPTIONS;
|
|
145
|
+
const c = partial?.color;
|
|
146
|
+
return {
|
|
147
|
+
enabled: partial?.enabled ?? d.enabled,
|
|
148
|
+
threshold: partial?.threshold ?? d.threshold,
|
|
149
|
+
knee: partial?.knee ?? d.knee,
|
|
150
|
+
radius: partial?.radius ?? d.radius,
|
|
151
|
+
color: c ? new Vec3(c.x, c.y, c.z) : new Vec3(d.color.x, d.color.y, d.color.z),
|
|
152
|
+
intensity: partial?.intensity ?? d.intensity,
|
|
153
|
+
clamp: partial?.clamp ?? d.clamp,
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
static mergeViewTransformDefaults(partial) {
|
|
157
|
+
const d = DEFAULT_VIEW_TRANSFORM;
|
|
158
|
+
return {
|
|
159
|
+
exposure: partial?.exposure ?? d.exposure,
|
|
160
|
+
gamma: partial?.gamma ?? d.gamma,
|
|
161
|
+
look: partial?.look ?? d.look,
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
/** Current bloom settings (Blender names; tint is a copied `Vec3`). */
|
|
165
|
+
getBloomOptions() {
|
|
166
|
+
const b = this.bloomSettings;
|
|
167
|
+
return {
|
|
168
|
+
enabled: b.enabled,
|
|
169
|
+
threshold: b.threshold,
|
|
170
|
+
knee: b.knee,
|
|
171
|
+
radius: b.radius,
|
|
172
|
+
color: new Vec3(b.color.x, b.color.y, b.color.z),
|
|
173
|
+
intensity: b.intensity,
|
|
174
|
+
clamp: b.clamp,
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
getViewTransformOptions() {
|
|
178
|
+
const v = this.viewTransform;
|
|
179
|
+
return { exposure: v.exposure, gamma: v.gamma, look: v.look };
|
|
180
|
+
}
|
|
181
|
+
setViewTransformOptions(patch) {
|
|
182
|
+
const v = this.viewTransform;
|
|
183
|
+
if (patch.exposure !== undefined)
|
|
184
|
+
v.exposure = patch.exposure;
|
|
185
|
+
if (patch.gamma !== undefined)
|
|
186
|
+
v.gamma = patch.gamma;
|
|
187
|
+
if (patch.look !== undefined)
|
|
188
|
+
v.look = patch.look;
|
|
189
|
+
if (this.device && this.compositeUniformBuffer) {
|
|
190
|
+
this.writeCompositeViewUniforms();
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
writeCompositeViewUniforms() {
|
|
194
|
+
const v = this.viewTransform;
|
|
195
|
+
const b = this.bloomSettings;
|
|
196
|
+
const effIntensity = b.enabled ? b.intensity : 0.0;
|
|
197
|
+
const u = this.compositeUniformData;
|
|
198
|
+
u[0] = v.exposure;
|
|
199
|
+
u[1] = Math.max(v.gamma, 1e-4);
|
|
200
|
+
u[2] = 0.0;
|
|
201
|
+
u[3] = 0.0;
|
|
202
|
+
u[4] = b.color.x;
|
|
203
|
+
u[5] = b.color.y;
|
|
204
|
+
u[6] = b.color.z;
|
|
205
|
+
u[7] = effIntensity;
|
|
206
|
+
this.device.queue.writeBuffer(this.compositeUniformBuffer, 0, u);
|
|
207
|
+
}
|
|
208
|
+
/** Patch bloom; GPU uniforms update immediately if `init()` has run. */
|
|
209
|
+
setBloomOptions(patch) {
|
|
210
|
+
const b = this.bloomSettings;
|
|
211
|
+
if (patch.enabled !== undefined)
|
|
212
|
+
b.enabled = patch.enabled;
|
|
213
|
+
if (patch.threshold !== undefined)
|
|
214
|
+
b.threshold = patch.threshold;
|
|
215
|
+
if (patch.knee !== undefined)
|
|
216
|
+
b.knee = patch.knee;
|
|
217
|
+
if (patch.radius !== undefined)
|
|
218
|
+
b.radius = patch.radius;
|
|
219
|
+
if (patch.color !== undefined) {
|
|
220
|
+
b.color.x = patch.color.x;
|
|
221
|
+
b.color.y = patch.color.y;
|
|
222
|
+
b.color.z = patch.color.z;
|
|
223
|
+
}
|
|
224
|
+
if (patch.intensity !== undefined)
|
|
225
|
+
b.intensity = patch.intensity;
|
|
226
|
+
if (patch.clamp !== undefined)
|
|
227
|
+
b.clamp = patch.clamp;
|
|
228
|
+
if (this.device && this.bloomBlitUniformBuffer) {
|
|
229
|
+
this.writeBloomUniforms();
|
|
230
|
+
this.writeCompositeViewUniforms();
|
|
104
231
|
}
|
|
105
232
|
}
|
|
233
|
+
// EEVEE prefilter uniforms (blit stage) + upsample sample scale. Intensity/tint live in composite.
|
|
234
|
+
writeBloomUniforms() {
|
|
235
|
+
const b = this.bloomSettings;
|
|
236
|
+
const bu = this.bloomBlitUniformData;
|
|
237
|
+
// EEVEE prefilter: threshold, knee, clamp (0 → disabled), _unused
|
|
238
|
+
bu[0] = b.threshold;
|
|
239
|
+
bu[1] = b.knee;
|
|
240
|
+
bu[2] = b.clamp;
|
|
241
|
+
bu[3] = 0.0;
|
|
242
|
+
this.device.queue.writeBuffer(this.bloomBlitUniformBuffer, 0, bu);
|
|
243
|
+
const us = this.bloomUpsampleUniformData;
|
|
244
|
+
// Blender: bloom.radius directly controls the tent-filter sample scale in texel units.
|
|
245
|
+
us[0] = Math.max(0.5, b.radius);
|
|
246
|
+
us[1] = 0;
|
|
247
|
+
us[2] = 0;
|
|
248
|
+
us[3] = 0;
|
|
249
|
+
this.device.queue.writeBuffer(this.bloomUpsampleUniformBuffer, 0, us);
|
|
250
|
+
}
|
|
106
251
|
// Step 1: Get WebGPU device and context
|
|
107
252
|
async init() {
|
|
108
253
|
const adapter = await navigator.gpu?.requestAdapter();
|
|
@@ -128,6 +273,69 @@ export class Engine {
|
|
|
128
273
|
this.setupResize();
|
|
129
274
|
Engine.instance = this;
|
|
130
275
|
}
|
|
276
|
+
// One-shot bake of EEVEE's BRDF split-sum DFG LUT — ported from
|
|
277
|
+
// bsdf_lut_frag.glsl. Runs once per engine init; resulting 64×64 rg16float
|
|
278
|
+
// texture is sampled by every material shader via group(0) binding(9).
|
|
279
|
+
bakeDfgLut() {
|
|
280
|
+
this.dfgLutTexture = this.device.createTexture({
|
|
281
|
+
label: "DFG LUT",
|
|
282
|
+
size: [DFG_LUT_SIZE, DFG_LUT_SIZE],
|
|
283
|
+
format: "rg16float",
|
|
284
|
+
usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING,
|
|
285
|
+
});
|
|
286
|
+
this.dfgLutView = this.dfgLutTexture.createView();
|
|
287
|
+
const module = this.device.createShaderModule({ label: "DFG LUT bake", code: DFG_LUT_WGSL });
|
|
288
|
+
const pipeline = this.device.createRenderPipeline({
|
|
289
|
+
label: "DFG LUT bake pipeline",
|
|
290
|
+
layout: "auto",
|
|
291
|
+
vertex: { module, entryPoint: "vs" },
|
|
292
|
+
fragment: { module, entryPoint: "fs", targets: [{ format: "rg16float" }] },
|
|
293
|
+
primitive: { topology: "triangle-list" },
|
|
294
|
+
});
|
|
295
|
+
const enc = this.device.createCommandEncoder({ label: "DFG LUT bake encoder" });
|
|
296
|
+
const pass = enc.beginRenderPass({
|
|
297
|
+
colorAttachments: [
|
|
298
|
+
{ view: this.dfgLutView, clearValue: { r: 0, g: 0, b: 0, a: 1 }, loadOp: "clear", storeOp: "store" },
|
|
299
|
+
],
|
|
300
|
+
});
|
|
301
|
+
pass.setPipeline(pipeline);
|
|
302
|
+
pass.draw(3, 1, 0, 0);
|
|
303
|
+
pass.end();
|
|
304
|
+
this.device.queue.submit([enc.finish()]);
|
|
305
|
+
}
|
|
306
|
+
// Upload Blender's static LTC GGX magnitude LUT (eevee_lut.c ltc_mag_ggx[]).
|
|
307
|
+
// Pairs with the DFG LUT to form ltc_brdf_scale — closure_eval_glossy_lib.glsl:79-81.
|
|
308
|
+
uploadLtcMagLut() {
|
|
309
|
+
this.ltcMagLutTexture = this.device.createTexture({
|
|
310
|
+
label: "LTC mag LUT",
|
|
311
|
+
size: [LTC_MAG_LUT_SIZE, LTC_MAG_LUT_SIZE],
|
|
312
|
+
format: "rg16float",
|
|
313
|
+
usage: GPUTextureUsage.COPY_DST | GPUTextureUsage.TEXTURE_BINDING,
|
|
314
|
+
});
|
|
315
|
+
this.ltcMagLutView = this.ltcMagLutTexture.createView();
|
|
316
|
+
// Float32 → float16 bits. rg16float writeTexture expects packed half floats.
|
|
317
|
+
const n = LTC_MAG_LUT_DATA.length;
|
|
318
|
+
const half = new Uint16Array(n);
|
|
319
|
+
const f32 = new Float32Array(1);
|
|
320
|
+
const u32 = new Uint32Array(f32.buffer);
|
|
321
|
+
for (let i = 0; i < n; i++) {
|
|
322
|
+
f32[0] = LTC_MAG_LUT_DATA[i];
|
|
323
|
+
const x = u32[0];
|
|
324
|
+
const sign = (x >>> 16) & 0x8000;
|
|
325
|
+
let exp = ((x >>> 23) & 0xff) - 127 + 15;
|
|
326
|
+
let mant = x & 0x7fffff;
|
|
327
|
+
if (exp <= 0) {
|
|
328
|
+
half[i] = sign; // flush tiny values to signed zero (data here is in [0, ~1])
|
|
329
|
+
}
|
|
330
|
+
else if (exp >= 31) {
|
|
331
|
+
half[i] = sign | 0x7c00; // inf
|
|
332
|
+
}
|
|
333
|
+
else {
|
|
334
|
+
half[i] = sign | (exp << 10) | (mant >>> 13);
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
this.device.queue.writeTexture({ texture: this.ltcMagLutTexture }, half, { bytesPerRow: LTC_MAG_LUT_SIZE * 4, rowsPerImage: LTC_MAG_LUT_SIZE }, { width: LTC_MAG_LUT_SIZE, height: LTC_MAG_LUT_SIZE, depthOrArrayLayers: 1 });
|
|
338
|
+
}
|
|
131
339
|
createRenderPipeline(config) {
|
|
132
340
|
return this.device.createRenderPipeline({
|
|
133
341
|
label: config.label,
|
|
@@ -191,8 +399,11 @@ export class Engine {
|
|
|
191
399
|
attributes: [{ shaderLocation: 4, offset: 0, format: "unorm8x4" }],
|
|
192
400
|
},
|
|
193
401
|
];
|
|
402
|
+
// Internal scene passes render into the HDR offscreen target; only the final
|
|
403
|
+
// composite pass writes the swapchain. Tonemap moved to composite so bloom
|
|
404
|
+
// (added next) can run on linear HDR.
|
|
194
405
|
const standardBlend = {
|
|
195
|
-
format:
|
|
406
|
+
format: Engine.HDR_FORMAT,
|
|
196
407
|
blend: {
|
|
197
408
|
color: {
|
|
198
409
|
srcFactor: "src-alpha",
|
|
@@ -207,145 +418,59 @@ export class Engine {
|
|
|
207
418
|
},
|
|
208
419
|
};
|
|
209
420
|
const shaderModule = this.device.createShaderModule({
|
|
210
|
-
label: "model
|
|
211
|
-
code:
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
diffuseColor: vec3f,
|
|
237
|
-
_padding3: f32,
|
|
238
|
-
ambientColor: vec3f,
|
|
239
|
-
_padding4: f32,
|
|
240
|
-
specularColor: vec3f,
|
|
241
|
-
_padding5: f32,
|
|
242
|
-
};
|
|
243
|
-
|
|
244
|
-
struct VertexOutput {
|
|
245
|
-
@builtin(position) position: vec4f,
|
|
246
|
-
@location(0) normal: vec3f,
|
|
247
|
-
@location(1) uv: vec2f,
|
|
248
|
-
@location(2) worldPos: vec3f,
|
|
249
|
-
};
|
|
250
|
-
|
|
251
|
-
// group 0: per-frame (bound once per pass)
|
|
252
|
-
@group(0) @binding(0) var<uniform> camera: CameraUniforms;
|
|
253
|
-
@group(0) @binding(1) var<uniform> light: LightUniforms;
|
|
254
|
-
@group(0) @binding(2) var diffuseSampler: sampler;
|
|
255
|
-
// group 1: per-instance (bound once per model)
|
|
256
|
-
@group(1) @binding(0) var<storage, read> skinMats: array<mat4x4f>;
|
|
257
|
-
// group 2: per-material (bound per draw call)
|
|
258
|
-
@group(2) @binding(0) var diffuseTexture: texture_2d<f32>;
|
|
259
|
-
@group(2) @binding(1) var<uniform> material: MaterialUniforms;
|
|
260
|
-
|
|
261
|
-
@vertex fn vs(
|
|
262
|
-
@location(0) position: vec3f,
|
|
263
|
-
@location(1) normal: vec3f,
|
|
264
|
-
@location(2) uv: vec2f,
|
|
265
|
-
@location(3) joints0: vec4<u32>,
|
|
266
|
-
@location(4) weights0: vec4<f32>
|
|
267
|
-
) -> VertexOutput {
|
|
268
|
-
var output: VertexOutput;
|
|
269
|
-
let pos4 = vec4f(position, 1.0);
|
|
270
|
-
|
|
271
|
-
let weightSum = weights0.x + weights0.y + weights0.z + weights0.w;
|
|
272
|
-
let invWeightSum = select(1.0, 1.0 / weightSum, weightSum > 0.0001);
|
|
273
|
-
let normalizedWeights = select(vec4f(1.0, 0.0, 0.0, 0.0), weights0 * invWeightSum, weightSum > 0.0001);
|
|
274
|
-
|
|
275
|
-
var skinnedPos = vec4f(0.0, 0.0, 0.0, 0.0);
|
|
276
|
-
var skinnedNrm = vec3f(0.0, 0.0, 0.0);
|
|
277
|
-
for (var i = 0u; i < 4u; i++) {
|
|
278
|
-
let j = joints0[i];
|
|
279
|
-
let w = normalizedWeights[i];
|
|
280
|
-
let m = skinMats[j];
|
|
281
|
-
skinnedPos += (m * pos4) * w;
|
|
282
|
-
let r3 = mat3x3f(m[0].xyz, m[1].xyz, m[2].xyz);
|
|
283
|
-
skinnedNrm += (r3 * normal) * w;
|
|
284
|
-
}
|
|
285
|
-
let worldPos = skinnedPos.xyz;
|
|
286
|
-
output.position = camera.projection * camera.view * vec4f(worldPos, 1.0);
|
|
287
|
-
output.normal = normalize(skinnedNrm);
|
|
288
|
-
output.uv = uv;
|
|
289
|
-
output.worldPos = worldPos;
|
|
290
|
-
return output;
|
|
291
|
-
}
|
|
292
|
-
|
|
293
|
-
@fragment fn fs(input: VertexOutput) -> @location(0) vec4f {
|
|
294
|
-
let finalAlpha = material.alpha;
|
|
295
|
-
if (finalAlpha < 0.001) {
|
|
296
|
-
discard;
|
|
297
|
-
}
|
|
298
|
-
|
|
299
|
-
let n = normalize(input.normal);
|
|
300
|
-
let textureColor = textureSample(diffuseTexture, diffuseSampler, input.uv).rgb;
|
|
301
|
-
|
|
302
|
-
let viewDir = normalize(camera.viewPos - input.worldPos);
|
|
303
|
-
|
|
304
|
-
let albedo = textureColor * material.diffuseColor;
|
|
305
|
-
|
|
306
|
-
let minSpec = light.ambientColor.w;
|
|
307
|
-
let effectiveSpecular = max(material.specularColor, vec3f(minSpec));
|
|
308
|
-
let specPower = max(material.shininess, 1.0);
|
|
309
|
-
|
|
310
|
-
let l = -light.lights[0].direction.xyz;
|
|
311
|
-
let nDotL = max(dot(n, l), 0.0);
|
|
312
|
-
let intensity = light.lights[0].color.w;
|
|
313
|
-
let radiance = light.lights[0].color.xyz * intensity;
|
|
314
|
-
|
|
315
|
-
let lightAccum = light.ambientColor.xyz + radiance * nDotL;
|
|
316
|
-
|
|
317
|
-
let h = normalize(l + viewDir);
|
|
318
|
-
let nDotH = max(dot(n, h), 0.0);
|
|
319
|
-
let specFactor = pow(nDotH, specPower);
|
|
320
|
-
let specularAccum = effectiveSpecular * radiance * specFactor * nDotL;
|
|
321
|
-
|
|
322
|
-
let litColor = albedo * lightAccum;
|
|
323
|
-
|
|
324
|
-
let fresnel = 1.0 - abs(dot(n, viewDir));
|
|
325
|
-
let rimFactor = pow(fresnel, 4.0);
|
|
326
|
-
let rimLight = material.rimColor * material.rimIntensity * rimFactor;
|
|
327
|
-
|
|
328
|
-
let color = litColor + specularAccum + rimLight;
|
|
329
|
-
|
|
330
|
-
return vec4f(color, finalAlpha);
|
|
331
|
-
}
|
|
332
|
-
`,
|
|
421
|
+
label: "default model shader",
|
|
422
|
+
code: DEFAULT_SHADER_WGSL,
|
|
423
|
+
});
|
|
424
|
+
const faceShaderModule = this.device.createShaderModule({
|
|
425
|
+
label: "face NPR shader",
|
|
426
|
+
code: FACE_SHADER_WGSL,
|
|
427
|
+
});
|
|
428
|
+
const hairShaderModule = this.device.createShaderModule({
|
|
429
|
+
label: "hair NPR shader",
|
|
430
|
+
code: HAIR_SHADER_WGSL,
|
|
431
|
+
});
|
|
432
|
+
const clothSmoothShaderModule = this.device.createShaderModule({
|
|
433
|
+
label: "cloth smooth NPR shader",
|
|
434
|
+
code: CLOTH_SMOOTH_SHADER_WGSL,
|
|
435
|
+
});
|
|
436
|
+
const clothRoughShaderModule = this.device.createShaderModule({
|
|
437
|
+
label: "cloth rough NPR shader",
|
|
438
|
+
code: CLOTH_ROUGH_SHADER_WGSL,
|
|
439
|
+
});
|
|
440
|
+
const metalShaderModule = this.device.createShaderModule({
|
|
441
|
+
label: "metal NPR shader",
|
|
442
|
+
code: METAL_SHADER_WGSL,
|
|
443
|
+
});
|
|
444
|
+
const bodyShaderModule = this.device.createShaderModule({
|
|
445
|
+
label: "body NPR shader",
|
|
446
|
+
code: BODY_SHADER_WGSL,
|
|
333
447
|
});
|
|
334
|
-
|
|
448
|
+
const eyeShaderModule = this.device.createShaderModule({
|
|
449
|
+
label: "eye shader",
|
|
450
|
+
code: EYE_SHADER_WGSL,
|
|
451
|
+
});
|
|
452
|
+
const stockingsShaderModule = this.device.createShaderModule({
|
|
453
|
+
label: "stockings NPR shader",
|
|
454
|
+
code: STOCKINGS_SHADER_WGSL,
|
|
455
|
+
});
|
|
456
|
+
// group 0: per-frame (camera + light + sampler + shadow) — bound once per pass
|
|
335
457
|
this.mainPerFrameBindGroupLayout = this.device.createBindGroupLayout({
|
|
336
458
|
label: "main per-frame bind group layout",
|
|
337
459
|
entries: [
|
|
338
460
|
{ binding: 0, visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } },
|
|
339
461
|
{ binding: 1, visibility: GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } },
|
|
340
462
|
{ binding: 2, visibility: GPUShaderStage.FRAGMENT, sampler: {} },
|
|
463
|
+
{ binding: 3, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: "depth" } },
|
|
464
|
+
{ binding: 4, visibility: GPUShaderStage.FRAGMENT, sampler: { type: "comparison" } },
|
|
465
|
+
{ binding: 5, visibility: GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } },
|
|
466
|
+
{ binding: 9, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: "float" } },
|
|
467
|
+
{ binding: 10, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: "float" } },
|
|
341
468
|
],
|
|
342
469
|
});
|
|
343
470
|
// group 1: per-instance (skinMats) — bound once per model
|
|
344
471
|
this.mainPerInstanceBindGroupLayout = this.device.createBindGroupLayout({
|
|
345
472
|
label: "main per-instance bind group layout",
|
|
346
|
-
entries: [
|
|
347
|
-
{ binding: 0, visibility: GPUShaderStage.VERTEX, buffer: { type: "read-only-storage" } },
|
|
348
|
-
],
|
|
473
|
+
entries: [{ binding: 0, visibility: GPUShaderStage.VERTEX, buffer: { type: "read-only-storage" } }],
|
|
349
474
|
});
|
|
350
475
|
// group 2: per-material (texture + material uniforms) — bound per draw call
|
|
351
476
|
this.mainPerMaterialBindGroupLayout = this.device.createBindGroupLayout({
|
|
@@ -357,17 +482,13 @@ export class Engine {
|
|
|
357
482
|
});
|
|
358
483
|
const mainPipelineLayout = this.device.createPipelineLayout({
|
|
359
484
|
label: "main pipeline layout",
|
|
360
|
-
bindGroupLayouts: [
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
layout: this.mainPerFrameBindGroupLayout,
|
|
365
|
-
entries: [
|
|
366
|
-
{ binding: 0, resource: { buffer: this.cameraUniformBuffer } },
|
|
367
|
-
{ binding: 1, resource: { buffer: this.lightUniformBuffer } },
|
|
368
|
-
{ binding: 2, resource: this.materialSampler },
|
|
485
|
+
bindGroupLayouts: [
|
|
486
|
+
this.mainPerFrameBindGroupLayout,
|
|
487
|
+
this.mainPerInstanceBindGroupLayout,
|
|
488
|
+
this.mainPerMaterialBindGroupLayout,
|
|
369
489
|
],
|
|
370
490
|
});
|
|
491
|
+
// perFrameBindGroup is created after shadow resources below
|
|
371
492
|
this.modelPipeline = this.createRenderPipeline({
|
|
372
493
|
label: "model pipeline",
|
|
373
494
|
layout: mainPipelineLayout,
|
|
@@ -381,6 +502,110 @@ export class Engine {
|
|
|
381
502
|
depthCompare: "less-equal",
|
|
382
503
|
},
|
|
383
504
|
});
|
|
505
|
+
this.facePipeline = this.createRenderPipeline({
|
|
506
|
+
label: "face NPR pipeline",
|
|
507
|
+
layout: mainPipelineLayout,
|
|
508
|
+
shaderModule: faceShaderModule,
|
|
509
|
+
vertexBuffers: fullVertexBuffers,
|
|
510
|
+
fragmentTarget: standardBlend,
|
|
511
|
+
cullMode: "none",
|
|
512
|
+
depthStencil: {
|
|
513
|
+
format: "depth24plus-stencil8",
|
|
514
|
+
depthWriteEnabled: true,
|
|
515
|
+
depthCompare: "less-equal",
|
|
516
|
+
},
|
|
517
|
+
});
|
|
518
|
+
this.hairPipeline = this.createRenderPipeline({
|
|
519
|
+
label: "hair NPR pipeline",
|
|
520
|
+
layout: mainPipelineLayout,
|
|
521
|
+
shaderModule: hairShaderModule,
|
|
522
|
+
vertexBuffers: fullVertexBuffers,
|
|
523
|
+
fragmentTarget: standardBlend,
|
|
524
|
+
cullMode: "none",
|
|
525
|
+
depthStencil: {
|
|
526
|
+
format: "depth24plus-stencil8",
|
|
527
|
+
depthWriteEnabled: true,
|
|
528
|
+
depthCompare: "less-equal",
|
|
529
|
+
},
|
|
530
|
+
});
|
|
531
|
+
this.clothSmoothPipeline = this.createRenderPipeline({
|
|
532
|
+
label: "cloth smooth NPR pipeline",
|
|
533
|
+
layout: mainPipelineLayout,
|
|
534
|
+
shaderModule: clothSmoothShaderModule,
|
|
535
|
+
vertexBuffers: fullVertexBuffers,
|
|
536
|
+
fragmentTarget: standardBlend,
|
|
537
|
+
cullMode: "none",
|
|
538
|
+
depthStencil: {
|
|
539
|
+
format: "depth24plus-stencil8",
|
|
540
|
+
depthWriteEnabled: true,
|
|
541
|
+
depthCompare: "less-equal",
|
|
542
|
+
},
|
|
543
|
+
});
|
|
544
|
+
this.clothRoughPipeline = this.createRenderPipeline({
|
|
545
|
+
label: "cloth rough NPR pipeline",
|
|
546
|
+
layout: mainPipelineLayout,
|
|
547
|
+
shaderModule: clothRoughShaderModule,
|
|
548
|
+
vertexBuffers: fullVertexBuffers,
|
|
549
|
+
fragmentTarget: standardBlend,
|
|
550
|
+
cullMode: "none",
|
|
551
|
+
depthStencil: {
|
|
552
|
+
format: "depth24plus-stencil8",
|
|
553
|
+
depthWriteEnabled: true,
|
|
554
|
+
depthCompare: "less-equal",
|
|
555
|
+
},
|
|
556
|
+
});
|
|
557
|
+
this.metalPipeline = this.createRenderPipeline({
|
|
558
|
+
label: "metal NPR pipeline",
|
|
559
|
+
layout: mainPipelineLayout,
|
|
560
|
+
shaderModule: metalShaderModule,
|
|
561
|
+
vertexBuffers: fullVertexBuffers,
|
|
562
|
+
fragmentTarget: standardBlend,
|
|
563
|
+
cullMode: "none",
|
|
564
|
+
depthStencil: {
|
|
565
|
+
format: "depth24plus-stencil8",
|
|
566
|
+
depthWriteEnabled: true,
|
|
567
|
+
depthCompare: "less-equal",
|
|
568
|
+
},
|
|
569
|
+
});
|
|
570
|
+
this.bodyPipeline = this.createRenderPipeline({
|
|
571
|
+
label: "body NPR pipeline",
|
|
572
|
+
layout: mainPipelineLayout,
|
|
573
|
+
shaderModule: bodyShaderModule,
|
|
574
|
+
vertexBuffers: fullVertexBuffers,
|
|
575
|
+
fragmentTarget: standardBlend,
|
|
576
|
+
cullMode: "none",
|
|
577
|
+
depthStencil: {
|
|
578
|
+
format: "depth24plus-stencil8",
|
|
579
|
+
depthWriteEnabled: true,
|
|
580
|
+
depthCompare: "less-equal",
|
|
581
|
+
},
|
|
582
|
+
});
|
|
583
|
+
this.eyePipeline = this.createRenderPipeline({
|
|
584
|
+
label: "eye pipeline",
|
|
585
|
+
layout: mainPipelineLayout,
|
|
586
|
+
shaderModule: eyeShaderModule,
|
|
587
|
+
vertexBuffers: fullVertexBuffers,
|
|
588
|
+
fragmentTarget: standardBlend,
|
|
589
|
+
cullMode: "none",
|
|
590
|
+
depthStencil: {
|
|
591
|
+
format: "depth24plus-stencil8",
|
|
592
|
+
depthWriteEnabled: true,
|
|
593
|
+
depthCompare: "less-equal",
|
|
594
|
+
},
|
|
595
|
+
});
|
|
596
|
+
this.stockingsPipeline = this.createRenderPipeline({
|
|
597
|
+
label: "stockings NPR pipeline",
|
|
598
|
+
layout: mainPipelineLayout,
|
|
599
|
+
shaderModule: stockingsShaderModule,
|
|
600
|
+
vertexBuffers: fullVertexBuffers,
|
|
601
|
+
fragmentTarget: standardBlend,
|
|
602
|
+
cullMode: "none",
|
|
603
|
+
depthStencil: {
|
|
604
|
+
format: "depth24plus-stencil8",
|
|
605
|
+
depthWriteEnabled: true,
|
|
606
|
+
depthCompare: "less-equal",
|
|
607
|
+
},
|
|
608
|
+
});
|
|
384
609
|
this.shadowLightVPBuffer = this.device.createBuffer({
|
|
385
610
|
size: 64,
|
|
386
611
|
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
|
|
@@ -429,6 +654,32 @@ export class Engine {
|
|
|
429
654
|
magFilter: "linear",
|
|
430
655
|
minFilter: "linear",
|
|
431
656
|
});
|
|
657
|
+
this.shadowMapTexture = this.device.createTexture({
|
|
658
|
+
label: "shadow map",
|
|
659
|
+
size: [Engine.SHADOW_MAP_SIZE, Engine.SHADOW_MAP_SIZE],
|
|
660
|
+
format: "depth32float",
|
|
661
|
+
usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING,
|
|
662
|
+
});
|
|
663
|
+
this.shadowMapDepthView = this.shadowMapTexture.createView();
|
|
664
|
+
// One-shot bake of Blender EEVEE's BRDF split-sum DFG LUT (bsdf_lut_frag.glsl).
|
|
665
|
+
this.bakeDfgLut();
|
|
666
|
+
// Upload static LTC GGX magnitude LUT for direct-specular energy compensation.
|
|
667
|
+
this.uploadLtcMagLut();
|
|
668
|
+
// Now that shadow resources exist, create the main per-frame bind group
|
|
669
|
+
this.perFrameBindGroup = this.device.createBindGroup({
|
|
670
|
+
label: "main per-frame bind group",
|
|
671
|
+
layout: this.mainPerFrameBindGroupLayout,
|
|
672
|
+
entries: [
|
|
673
|
+
{ binding: 0, resource: { buffer: this.cameraUniformBuffer } },
|
|
674
|
+
{ binding: 1, resource: { buffer: this.lightUniformBuffer } },
|
|
675
|
+
{ binding: 2, resource: this.materialSampler },
|
|
676
|
+
{ binding: 3, resource: this.shadowMapDepthView },
|
|
677
|
+
{ binding: 4, resource: this.shadowComparisonSampler },
|
|
678
|
+
{ binding: 5, resource: { buffer: this.shadowLightVPBuffer } },
|
|
679
|
+
{ binding: 9, resource: this.dfgLutView },
|
|
680
|
+
{ binding: 10, resource: this.ltcMagLutView },
|
|
681
|
+
],
|
|
682
|
+
});
|
|
432
683
|
this.groundShadowBindGroupLayout = this.device.createBindGroupLayout({
|
|
433
684
|
label: "ground shadow layout",
|
|
434
685
|
entries: [
|
|
@@ -557,14 +808,16 @@ export class Engine {
|
|
|
557
808
|
});
|
|
558
809
|
const outlinePipelineLayout = this.device.createPipelineLayout({
|
|
559
810
|
label: "outline pipeline layout",
|
|
560
|
-
bindGroupLayouts: [
|
|
811
|
+
bindGroupLayouts: [
|
|
812
|
+
this.outlinePerFrameBindGroupLayout,
|
|
813
|
+
this.mainPerInstanceBindGroupLayout,
|
|
814
|
+
this.outlinePerMaterialBindGroupLayout,
|
|
815
|
+
],
|
|
561
816
|
});
|
|
562
817
|
this.outlinePerFrameBindGroup = this.device.createBindGroup({
|
|
563
818
|
label: "outline per-frame bind group",
|
|
564
819
|
layout: this.outlinePerFrameBindGroupLayout,
|
|
565
|
-
entries: [
|
|
566
|
-
{ binding: 0, resource: { buffer: this.cameraUniformBuffer } },
|
|
567
|
-
],
|
|
820
|
+
entries: [{ binding: 0, resource: { buffer: this.cameraUniformBuffer } }],
|
|
568
821
|
});
|
|
569
822
|
const outlineShaderModule = this.device.createShaderModule({
|
|
570
823
|
label: "outline shaders",
|
|
@@ -620,12 +873,27 @@ export class Engine {
|
|
|
620
873
|
}
|
|
621
874
|
let worldPos = skinnedPos.xyz;
|
|
622
875
|
let worldNormal = normalize(skinnedNrm);
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
876
|
+
|
|
877
|
+
// Screen-space outline extrusion — MMD-style pixel-stable edge line.
|
|
878
|
+
// 1. Project position and normal-as-direction to clip space.
|
|
879
|
+
// 2. Normalize the 2D clip-space normal, aspect-compensated so "one pixel horizontally"
|
|
880
|
+
// matches "one pixel vertically" (otherwise wide viewports squash the outline in X).
|
|
881
|
+
// 3. Offset clip-space xy by (normal * edgeSize * edgeScale), then multiply by w
|
|
882
|
+
// so the perspective divide cancels out → offset stays constant in NDC regardless
|
|
883
|
+
// of depth, matching how MMD / babylon-mmd style outlines look identical when zooming.
|
|
884
|
+
// 4. edgeScale is in NDC-y units per PMX edgeSize. ≈ 0.006 gives ~3px at 1080p; it's
|
|
885
|
+
// tied to viewport HEIGHT so resizing the window keeps pixel thickness stable.
|
|
886
|
+
let viewProj = camera.projection * camera.view;
|
|
887
|
+
let clipPos = viewProj * vec4f(worldPos, 1.0);
|
|
888
|
+
let clipNormal = (viewProj * vec4f(worldNormal, 0.0)).xy;
|
|
889
|
+
// projection is column-major: proj[0][0] = 1/(aspect·tan(fov/2)), proj[1][1] = 1/tan(fov/2).
|
|
890
|
+
// Ratio proj[1][1]/proj[0][0] recovers the viewport aspect (width/height).
|
|
891
|
+
let aspect = camera.projection[1][1] / camera.projection[0][0];
|
|
892
|
+
let pixelDir = normalize(vec2f(clipNormal.x * aspect, clipNormal.y));
|
|
893
|
+
let ndcDir = vec2f(pixelDir.x / aspect, pixelDir.y);
|
|
894
|
+
let edgeScale = 0.0016;
|
|
895
|
+
let offset = ndcDir * material.edgeSize * edgeScale * clipPos.w;
|
|
896
|
+
output.position = vec4f(clipPos.xy + offset, clipPos.z, clipPos.w);
|
|
629
897
|
return output;
|
|
630
898
|
}
|
|
631
899
|
|
|
@@ -648,6 +916,277 @@ export class Engine {
|
|
|
648
916
|
depthCompare: "less-equal",
|
|
649
917
|
},
|
|
650
918
|
});
|
|
919
|
+
// ─── Bloom (EEVEE 3.6 pyramid): blit(Karis prefilter) → 13-tap downsamples → 9-tap tent upsamples ───
|
|
920
|
+
// Mirrors source/blender/draw/engines/eevee/shaders/effect_bloom_frag.glsl.
|
|
921
|
+
// Firefly suppression lives in the blit (Karis luminance-weighted 4-tap average). A single-pass
|
|
922
|
+
// Gaussian cannot reproduce this — hot pixels dominate and produce the sparkle halo.
|
|
923
|
+
this.bloomSampler = this.device.createSampler({
|
|
924
|
+
label: "bloom sampler",
|
|
925
|
+
magFilter: "linear",
|
|
926
|
+
minFilter: "linear",
|
|
927
|
+
addressModeU: "clamp-to-edge",
|
|
928
|
+
addressModeV: "clamp-to-edge",
|
|
929
|
+
});
|
|
930
|
+
this.bloomBlitUniformBuffer = this.device.createBuffer({
|
|
931
|
+
label: "bloom blit uniforms",
|
|
932
|
+
size: 16,
|
|
933
|
+
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
|
|
934
|
+
});
|
|
935
|
+
this.bloomUpsampleUniformBuffer = this.device.createBuffer({
|
|
936
|
+
label: "bloom upsample uniforms",
|
|
937
|
+
size: 16,
|
|
938
|
+
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
|
|
939
|
+
});
|
|
940
|
+
this.bloomBlitBindGroupLayout = this.device.createBindGroupLayout({
|
|
941
|
+
label: "bloom blit layout",
|
|
942
|
+
entries: [
|
|
943
|
+
{ binding: 0, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: "unfilterable-float" } },
|
|
944
|
+
{ binding: 1, visibility: GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } },
|
|
945
|
+
],
|
|
946
|
+
});
|
|
947
|
+
this.bloomDownsampleBindGroupLayout = this.device.createBindGroupLayout({
|
|
948
|
+
label: "bloom downsample layout",
|
|
949
|
+
entries: [
|
|
950
|
+
{ binding: 0, visibility: GPUShaderStage.FRAGMENT, texture: {} },
|
|
951
|
+
{ binding: 1, visibility: GPUShaderStage.FRAGMENT, sampler: {} },
|
|
952
|
+
],
|
|
953
|
+
});
|
|
954
|
+
this.bloomUpsampleBindGroupLayout = this.device.createBindGroupLayout({
|
|
955
|
+
label: "bloom upsample layout",
|
|
956
|
+
entries: [
|
|
957
|
+
{ binding: 0, visibility: GPUShaderStage.FRAGMENT, texture: {} }, // coarser-mip accumulator
|
|
958
|
+
{ binding: 1, visibility: GPUShaderStage.FRAGMENT, texture: {} }, // matching downsample mip (base add)
|
|
959
|
+
{ binding: 2, visibility: GPUShaderStage.FRAGMENT, sampler: {} },
|
|
960
|
+
{ binding: 3, visibility: GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } },
|
|
961
|
+
],
|
|
962
|
+
});
|
|
963
|
+
const bloomFullscreenVs = /* wgsl */ `
|
|
964
|
+
@vertex fn vs(@builtin(vertex_index) vi: u32) -> @builtin(position) vec4f {
|
|
965
|
+
let x = f32((vi & 1u) << 2u) - 1.0;
|
|
966
|
+
let y = f32((vi & 2u) << 1u) - 1.0;
|
|
967
|
+
return vec4f(x, y, 0.0, 1.0);
|
|
968
|
+
}
|
|
969
|
+
`;
|
|
970
|
+
// Blit: full-res HDR → half-res. Karis 4-tap firefly average + EEVEE quadratic knee threshold + clamp.
|
|
971
|
+
const bloomBlitShader = this.device.createShaderModule({
|
|
972
|
+
label: "bloom blit (Karis prefilter)",
|
|
973
|
+
code: `${bloomFullscreenVs}
|
|
974
|
+
@group(0) @binding(0) var hdrTex: texture_2d<f32>;
|
|
975
|
+
@group(0) @binding(1) var<uniform> prefilter: vec4<f32>; // threshold, knee, clamp, _unused
|
|
976
|
+
|
|
977
|
+
fn luminance(c: vec3f) -> f32 {
|
|
978
|
+
return dot(max(c, vec3f(0.0)), vec3f(0.2126, 0.7152, 0.0722));
|
|
979
|
+
}
|
|
980
|
+
fn fetch(c: vec2<i32>, clampV: f32) -> vec3f {
|
|
981
|
+
let d = vec2<i32>(textureDimensions(hdrTex));
|
|
982
|
+
let cc = clamp(c, vec2<i32>(0), d - vec2<i32>(1));
|
|
983
|
+
let s = textureLoad(hdrTex, cc, 0);
|
|
984
|
+
// Scene pass uses src-alpha blend with clear alpha 0 → premultiplied. Unpremultiply.
|
|
985
|
+
let rgb = max(s.rgb / max(s.a, 1e-6), vec3f(0.0));
|
|
986
|
+
// Blender: clamp each tap BEFORE Karis average (eevee_bloom: color = min(clampIntensity, color)).
|
|
987
|
+
return select(rgb, min(rgb, vec3f(clampV)), clampV > 0.0);
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
@fragment fn fs(@builtin(position) p: vec4f) -> @location(0) vec4f {
|
|
991
|
+
let dst = vec2<i32>(p.xy - vec2f(0.5));
|
|
992
|
+
let base = dst * 2;
|
|
993
|
+
let clampV = prefilter.z;
|
|
994
|
+
let a = fetch(base + vec2<i32>(0, 0), clampV);
|
|
995
|
+
let b = fetch(base + vec2<i32>(1, 0), clampV);
|
|
996
|
+
let c = fetch(base + vec2<i32>(0, 1), clampV);
|
|
997
|
+
let d = fetch(base + vec2<i32>(1, 1), clampV);
|
|
998
|
+
// Karis partial average: weight each tap by 1/(1+luma) — suppresses fireflies.
|
|
999
|
+
let wa = 1.0 / (1.0 + luminance(a));
|
|
1000
|
+
let wb = 1.0 / (1.0 + luminance(b));
|
|
1001
|
+
let wc = 1.0 / (1.0 + luminance(c));
|
|
1002
|
+
let wd = 1.0 / (1.0 + luminance(d));
|
|
1003
|
+
let avg = (a * wa + b * wb + c * wc + d * wd) / max(wa + wb + wc + wd, 1e-6);
|
|
1004
|
+
// EEVEE quadratic threshold (brightness = max-channel, then soft-knee curve).
|
|
1005
|
+
let bright = max(avg.r, max(avg.g, avg.b));
|
|
1006
|
+
let soft = clamp(bright - prefilter.x + prefilter.y, 0.0, 2.0 * prefilter.y);
|
|
1007
|
+
let q = (soft * soft) / (4.0 * max(prefilter.y, 1e-4) + 1e-6);
|
|
1008
|
+
let contrib = max(q, bright - prefilter.x) / max(bright, 1e-4);
|
|
1009
|
+
return vec4f(max(avg * contrib, vec3f(0.0)), 1.0);
|
|
1010
|
+
}
|
|
1011
|
+
`,
|
|
1012
|
+
});
|
|
1013
|
+
// Downsample: Jimenez/COD 13-tap dual-box — 5 weighted 2×2 averages, rejects nyquist ringing.
|
|
1014
|
+
const bloomDownsampleShader = this.device.createShaderModule({
|
|
1015
|
+
label: "bloom downsample 13-tap",
|
|
1016
|
+
code: `${bloomFullscreenVs}
|
|
1017
|
+
@group(0) @binding(0) var srcTex: texture_2d<f32>;
|
|
1018
|
+
@group(0) @binding(1) var srcSamp: sampler;
|
|
1019
|
+
|
|
1020
|
+
fn samp(uv: vec2f, off: vec2f) -> vec3f {
|
|
1021
|
+
return textureSampleLevel(srcTex, srcSamp, uv + off, 0.0).rgb;
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
@fragment fn fs(@builtin(position) p: vec4f) -> @location(0) vec4f {
|
|
1025
|
+
let srcDims = vec2f(textureDimensions(srcTex));
|
|
1026
|
+
let t = 1.0 / srcDims;
|
|
1027
|
+
// fragCoord.xy reports pixel centers (e.g. 0.5,0.5 for first pixel) — divide by dst dims directly.
|
|
1028
|
+
let dstDims = srcDims * 0.5;
|
|
1029
|
+
let uv = p.xy / max(dstDims, vec2f(1.0));
|
|
1030
|
+
let A = samp(uv, t * vec2f(-2.0, -2.0));
|
|
1031
|
+
let B = samp(uv, t * vec2f( 0.0, -2.0));
|
|
1032
|
+
let C = samp(uv, t * vec2f( 2.0, -2.0));
|
|
1033
|
+
let D = samp(uv, t * vec2f(-1.0, -1.0));
|
|
1034
|
+
let E = samp(uv, t * vec2f( 1.0, -1.0));
|
|
1035
|
+
let F = samp(uv, t * vec2f(-2.0, 0.0));
|
|
1036
|
+
let G = samp(uv, t * vec2f( 0.0, 0.0));
|
|
1037
|
+
let H = samp(uv, t * vec2f( 2.0, 0.0));
|
|
1038
|
+
let I = samp(uv, t * vec2f(-1.0, 1.0));
|
|
1039
|
+
let J = samp(uv, t * vec2f( 1.0, 1.0));
|
|
1040
|
+
let K = samp(uv, t * vec2f(-2.0, 2.0));
|
|
1041
|
+
let L = samp(uv, t * vec2f( 0.0, 2.0));
|
|
1042
|
+
let M = samp(uv, t * vec2f( 2.0, 2.0));
|
|
1043
|
+
var o = (D + E + I + J) * (0.5 / 4.0);
|
|
1044
|
+
o = o + (A + B + G + F) * (0.125 / 4.0);
|
|
1045
|
+
o = o + (B + C + H + G) * (0.125 / 4.0);
|
|
1046
|
+
o = o + (F + G + L + K) * (0.125 / 4.0);
|
|
1047
|
+
o = o + (G + H + M + L) * (0.125 / 4.0);
|
|
1048
|
+
return vec4f(o, 1.0);
|
|
1049
|
+
}
|
|
1050
|
+
`,
|
|
1051
|
+
});
|
|
1052
|
+
// Upsample: 9-tap tent, progressively added to matching downsample mip. Blender radius = sample scale.
|
|
1053
|
+
const bloomUpsampleShader = this.device.createShaderModule({
|
|
1054
|
+
label: "bloom upsample 9-tap tent",
|
|
1055
|
+
code: `${bloomFullscreenVs}
|
|
1056
|
+
@group(0) @binding(0) var srcTex: texture_2d<f32>; // coarser accumulator
|
|
1057
|
+
@group(0) @binding(1) var baseTex: texture_2d<f32>; // matching downsample mip
|
|
1058
|
+
@group(0) @binding(2) var srcSamp: sampler;
|
|
1059
|
+
@group(0) @binding(3) var<uniform> upU: vec4<f32>; // sampleScale, _, _, _
|
|
1060
|
+
|
|
1061
|
+
@fragment fn fs(@builtin(position) p: vec4f) -> @location(0) vec4f {
|
|
1062
|
+
let srcDims = vec2f(textureDimensions(srcTex));
|
|
1063
|
+
let baseDims = vec2f(textureDimensions(baseTex));
|
|
1064
|
+
let uv = p.xy / max(baseDims, vec2f(1.0));
|
|
1065
|
+
let t = upU.x / srcDims;
|
|
1066
|
+
var o = textureSampleLevel(srcTex, srcSamp, uv + t * vec2f(-1.0, -1.0), 0.0).rgb * 1.0;
|
|
1067
|
+
o = o + textureSampleLevel(srcTex, srcSamp, uv + t * vec2f( 0.0, -1.0), 0.0).rgb * 2.0;
|
|
1068
|
+
o = o + textureSampleLevel(srcTex, srcSamp, uv + t * vec2f( 1.0, -1.0), 0.0).rgb * 1.0;
|
|
1069
|
+
o = o + textureSampleLevel(srcTex, srcSamp, uv + t * vec2f(-1.0, 0.0), 0.0).rgb * 2.0;
|
|
1070
|
+
o = o + textureSampleLevel(srcTex, srcSamp, uv + t * vec2f( 0.0, 0.0), 0.0).rgb * 4.0;
|
|
1071
|
+
o = o + textureSampleLevel(srcTex, srcSamp, uv + t * vec2f( 1.0, 0.0), 0.0).rgb * 2.0;
|
|
1072
|
+
o = o + textureSampleLevel(srcTex, srcSamp, uv + t * vec2f(-1.0, 1.0), 0.0).rgb * 1.0;
|
|
1073
|
+
o = o + textureSampleLevel(srcTex, srcSamp, uv + t * vec2f( 0.0, 1.0), 0.0).rgb * 2.0;
|
|
1074
|
+
o = o + textureSampleLevel(srcTex, srcSamp, uv + t * vec2f( 1.0, 1.0), 0.0).rgb * 1.0;
|
|
1075
|
+
o = o * (1.0 / 16.0);
|
|
1076
|
+
let base = textureSampleLevel(baseTex, srcSamp, uv, 0.0).rgb;
|
|
1077
|
+
return vec4f(o + base, 1.0);
|
|
1078
|
+
}
|
|
1079
|
+
`,
|
|
1080
|
+
});
|
|
1081
|
+
const bloomBlitLayout = this.device.createPipelineLayout({ bindGroupLayouts: [this.bloomBlitBindGroupLayout] });
|
|
1082
|
+
const bloomDownLayout = this.device.createPipelineLayout({ bindGroupLayouts: [this.bloomDownsampleBindGroupLayout] });
|
|
1083
|
+
const bloomUpLayout = this.device.createPipelineLayout({ bindGroupLayouts: [this.bloomUpsampleBindGroupLayout] });
|
|
1084
|
+
this.bloomBlitPipeline = this.device.createRenderPipeline({
|
|
1085
|
+
label: "bloom blit pipeline",
|
|
1086
|
+
layout: bloomBlitLayout,
|
|
1087
|
+
vertex: { module: bloomBlitShader, entryPoint: "vs" },
|
|
1088
|
+
fragment: { module: bloomBlitShader, entryPoint: "fs", targets: [{ format: Engine.HDR_FORMAT }] },
|
|
1089
|
+
primitive: { topology: "triangle-list" },
|
|
1090
|
+
});
|
|
1091
|
+
this.bloomDownsamplePipeline = this.device.createRenderPipeline({
|
|
1092
|
+
label: "bloom downsample pipeline",
|
|
1093
|
+
layout: bloomDownLayout,
|
|
1094
|
+
vertex: { module: bloomDownsampleShader, entryPoint: "vs" },
|
|
1095
|
+
fragment: { module: bloomDownsampleShader, entryPoint: "fs", targets: [{ format: Engine.HDR_FORMAT }] },
|
|
1096
|
+
primitive: { topology: "triangle-list" },
|
|
1097
|
+
});
|
|
1098
|
+
this.bloomUpsamplePipeline = this.device.createRenderPipeline({
|
|
1099
|
+
label: "bloom upsample pipeline",
|
|
1100
|
+
layout: bloomUpLayout,
|
|
1101
|
+
vertex: { module: bloomUpsampleShader, entryPoint: "vs" },
|
|
1102
|
+
fragment: { module: bloomUpsampleShader, entryPoint: "fs", targets: [{ format: Engine.HDR_FORMAT }] },
|
|
1103
|
+
primitive: { topology: "triangle-list" },
|
|
1104
|
+
});
|
|
1105
|
+
// ─── Composite: HDR + bloom → Filmic → swapchain (premultiplied) ───
|
|
1106
|
+
// Bloom color/intensity applied HERE (pyramid is pure energy; tint belongs to the combine step,
|
|
1107
|
+
// mirroring EEVEE where bloom color/intensity are combine-stage params, not prefilter).
|
|
1108
|
+
this.compositeUniformBuffer = this.device.createBuffer({
|
|
1109
|
+
label: "composite view uniforms",
|
|
1110
|
+
size: 32,
|
|
1111
|
+
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
|
|
1112
|
+
});
|
|
1113
|
+
this.compositeBindGroupLayout = this.device.createBindGroupLayout({
|
|
1114
|
+
label: "composite bind group layout",
|
|
1115
|
+
entries: [
|
|
1116
|
+
{ binding: 0, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: "unfilterable-float" } },
|
|
1117
|
+
{ binding: 1, visibility: GPUShaderStage.FRAGMENT, texture: {} },
|
|
1118
|
+
{ binding: 2, visibility: GPUShaderStage.FRAGMENT, sampler: {} },
|
|
1119
|
+
{ binding: 3, visibility: GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } },
|
|
1120
|
+
],
|
|
1121
|
+
});
|
|
1122
|
+
const compositeShader = this.device.createShaderModule({
|
|
1123
|
+
label: "composite shader",
|
|
1124
|
+
code: /* wgsl */ `
|
|
1125
|
+
@group(0) @binding(0) var hdrTex: texture_2d<f32>;
|
|
1126
|
+
@group(0) @binding(1) var bloomTex: texture_2d<f32>; // bloomUpTexture mip 0 (full pyramid top)
|
|
1127
|
+
@group(0) @binding(2) var bloomSamp: sampler;
|
|
1128
|
+
@group(0) @binding(3) var<uniform> viewU: array<vec4<f32>, 2>;
|
|
1129
|
+
// viewU[0] = (exposure, gamma, _, _); viewU[1] = (tint.rgb, intensity)
|
|
1130
|
+
|
|
1131
|
+
fn filmic(x: f32) -> f32 {
|
|
1132
|
+
var lut = array<f32, 14>(
|
|
1133
|
+
0.0067, 0.0141, 0.0272, 0.0499, 0.0885, 0.1512, 0.2462,
|
|
1134
|
+
0.3753, 0.5273, 0.6776, 0.8031, 0.8929, 0.9495, 0.9814
|
|
1135
|
+
);
|
|
1136
|
+
let t = clamp(log2(max(x, 1e-10)) + 10.0, 0.0, 13.0);
|
|
1137
|
+
let i = u32(t);
|
|
1138
|
+
let j = min(i + 1u, 13u);
|
|
1139
|
+
return mix(lut[i], lut[j], t - f32(i));
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1142
|
+
@vertex fn vs(@builtin(vertex_index) vi: u32) -> @builtin(position) vec4f {
|
|
1143
|
+
let x = f32((vi & 1u) << 2u) - 1.0;
|
|
1144
|
+
let y = f32((vi & 2u) << 1u) - 1.0;
|
|
1145
|
+
return vec4f(x, y, 0.0, 1.0);
|
|
1146
|
+
}
|
|
1147
|
+
|
|
1148
|
+
@fragment fn fs(@builtin(position) fragCoord: vec4f) -> @location(0) vec4f {
|
|
1149
|
+
let hdr = textureLoad(hdrTex, vec2<i32>(fragCoord.xy), 0);
|
|
1150
|
+
let a = max(hdr.a, 1e-6);
|
|
1151
|
+
let straight = hdr.rgb / a;
|
|
1152
|
+
let fullSz = vec2f(textureDimensions(hdrTex));
|
|
1153
|
+
let bloomSz = vec2f(textureDimensions(bloomTex));
|
|
1154
|
+
// Bloom is at half-res (pyramid mip 0). Sampler interpolates back to full-res UVs.
|
|
1155
|
+
let bloomUv = (fragCoord.xy + vec2f(0.5)) / max(fullSz, vec2f(1.0));
|
|
1156
|
+
let tint = viewU[1].xyz;
|
|
1157
|
+
let intensity = viewU[1].w;
|
|
1158
|
+
let bloom = textureSampleLevel(bloomTex, bloomSamp, bloomUv, 0.0).rgb * tint * intensity;
|
|
1159
|
+
let combined = straight + bloom;
|
|
1160
|
+
let exposed = combined * exp2(viewU[0].x);
|
|
1161
|
+
let tm = vec3f(filmic(exposed.r), filmic(exposed.g), filmic(exposed.b));
|
|
1162
|
+
let g = max(viewU[0].y, 1e-4);
|
|
1163
|
+
let disp = pow(max(tm, vec3f(0.0)), vec3f(1.0 / g));
|
|
1164
|
+
return vec4f(disp * hdr.a, hdr.a);
|
|
1165
|
+
}
|
|
1166
|
+
`,
|
|
1167
|
+
});
|
|
1168
|
+
this.compositePipeline = this.device.createRenderPipeline({
|
|
1169
|
+
label: "composite pipeline",
|
|
1170
|
+
layout: this.device.createPipelineLayout({ bindGroupLayouts: [this.compositeBindGroupLayout] }),
|
|
1171
|
+
vertex: { module: compositeShader, entryPoint: "vs" },
|
|
1172
|
+
fragment: {
|
|
1173
|
+
module: compositeShader,
|
|
1174
|
+
entryPoint: "fs",
|
|
1175
|
+
targets: [{ format: this.presentationFormat }],
|
|
1176
|
+
},
|
|
1177
|
+
primitive: { topology: "triangle-list" },
|
|
1178
|
+
});
|
|
1179
|
+
this.bloomPassDescriptor = {
|
|
1180
|
+
label: "bloom pass",
|
|
1181
|
+
colorAttachments: [
|
|
1182
|
+
{
|
|
1183
|
+
view: undefined,
|
|
1184
|
+
clearValue: { r: 0, g: 0, b: 0, a: 0 },
|
|
1185
|
+
loadOp: "clear",
|
|
1186
|
+
storeOp: "store",
|
|
1187
|
+
},
|
|
1188
|
+
],
|
|
1189
|
+
};
|
|
651
1190
|
// GPU picking: encode (modelIndex, materialIndex) as color
|
|
652
1191
|
const pickShaderModule = this.device.createShaderModule({
|
|
653
1192
|
label: "pick shader",
|
|
@@ -692,32 +1231,28 @@ export class Engine {
|
|
|
692
1231
|
});
|
|
693
1232
|
this.pickPerFrameBindGroupLayout = this.device.createBindGroupLayout({
|
|
694
1233
|
label: "pick per-frame layout",
|
|
695
|
-
entries: [
|
|
696
|
-
{ binding: 0, visibility: GPUShaderStage.VERTEX, buffer: { type: "uniform" } },
|
|
697
|
-
],
|
|
1234
|
+
entries: [{ binding: 0, visibility: GPUShaderStage.VERTEX, buffer: { type: "uniform" } }],
|
|
698
1235
|
});
|
|
699
1236
|
this.pickPerInstanceBindGroupLayout = this.device.createBindGroupLayout({
|
|
700
1237
|
label: "pick per-instance layout",
|
|
701
|
-
entries: [
|
|
702
|
-
{ binding: 0, visibility: GPUShaderStage.VERTEX, buffer: { type: "read-only-storage" } },
|
|
703
|
-
],
|
|
1238
|
+
entries: [{ binding: 0, visibility: GPUShaderStage.VERTEX, buffer: { type: "read-only-storage" } }],
|
|
704
1239
|
});
|
|
705
1240
|
this.pickPerMaterialBindGroupLayout = this.device.createBindGroupLayout({
|
|
706
1241
|
label: "pick per-material layout",
|
|
707
|
-
entries: [
|
|
708
|
-
{ binding: 0, visibility: GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } },
|
|
709
|
-
],
|
|
1242
|
+
entries: [{ binding: 0, visibility: GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } }],
|
|
710
1243
|
});
|
|
711
1244
|
const pickPipelineLayout = this.device.createPipelineLayout({
|
|
712
1245
|
label: "pick pipeline layout",
|
|
713
|
-
bindGroupLayouts: [
|
|
1246
|
+
bindGroupLayouts: [
|
|
1247
|
+
this.pickPerFrameBindGroupLayout,
|
|
1248
|
+
this.pickPerInstanceBindGroupLayout,
|
|
1249
|
+
this.pickPerMaterialBindGroupLayout,
|
|
1250
|
+
],
|
|
714
1251
|
});
|
|
715
1252
|
this.pickPerFrameBindGroup = this.device.createBindGroup({
|
|
716
1253
|
label: "pick per-frame bind group",
|
|
717
1254
|
layout: this.pickPerFrameBindGroupLayout,
|
|
718
|
-
entries: [
|
|
719
|
-
{ binding: 0, resource: { buffer: this.cameraUniformBuffer } },
|
|
720
|
-
],
|
|
1255
|
+
entries: [{ binding: 0, resource: { buffer: this.cameraUniformBuffer } }],
|
|
721
1256
|
});
|
|
722
1257
|
this.pickPipeline = this.device.createRenderPipeline({
|
|
723
1258
|
label: "pick pipeline",
|
|
@@ -761,12 +1296,47 @@ export class Engine {
|
|
|
761
1296
|
this.canvas.width = width;
|
|
762
1297
|
this.canvas.height = height;
|
|
763
1298
|
this.multisampleTexture = this.device.createTexture({
|
|
764
|
-
label: "multisample render target",
|
|
1299
|
+
label: "multisample HDR render target",
|
|
765
1300
|
size: [width, height],
|
|
766
1301
|
sampleCount: Engine.MULTISAMPLE_COUNT,
|
|
767
|
-
format:
|
|
1302
|
+
format: Engine.HDR_FORMAT,
|
|
768
1303
|
usage: GPUTextureUsage.RENDER_ATTACHMENT,
|
|
769
1304
|
});
|
|
1305
|
+
this.hdrResolveTexture = this.device.createTexture({
|
|
1306
|
+
label: "HDR resolve target",
|
|
1307
|
+
size: [width, height],
|
|
1308
|
+
format: Engine.HDR_FORMAT,
|
|
1309
|
+
usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING,
|
|
1310
|
+
});
|
|
1311
|
+
// Bloom pyramid: mip 0 is half-res, each subsequent mip halves again.
|
|
1312
|
+
// Mip count chosen so the coarsest mip is ≥4 px on the short side, capped at BLOOM_MAX_LEVELS.
|
|
1313
|
+
const bw = Math.max(1, Math.floor(width / 2));
|
|
1314
|
+
const bh = Math.max(1, Math.floor(height / 2));
|
|
1315
|
+
const shortSide = Math.max(1, Math.min(bw, bh));
|
|
1316
|
+
this.bloomMipCount = Math.max(1, Math.min(Engine.BLOOM_MAX_LEVELS, Math.floor(Math.log2(shortSide)) - 1));
|
|
1317
|
+
this.bloomDownTexture = this.device.createTexture({
|
|
1318
|
+
label: "bloom down pyramid",
|
|
1319
|
+
size: [bw, bh],
|
|
1320
|
+
mipLevelCount: this.bloomMipCount,
|
|
1321
|
+
format: Engine.HDR_FORMAT,
|
|
1322
|
+
usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING,
|
|
1323
|
+
});
|
|
1324
|
+
this.bloomUpTexture = this.device.createTexture({
|
|
1325
|
+
label: "bloom up pyramid",
|
|
1326
|
+
size: [bw, bh],
|
|
1327
|
+
mipLevelCount: Math.max(1, this.bloomMipCount - 1),
|
|
1328
|
+
format: Engine.HDR_FORMAT,
|
|
1329
|
+
usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING,
|
|
1330
|
+
});
|
|
1331
|
+
this.bloomDownMipViews = [];
|
|
1332
|
+
for (let i = 0; i < this.bloomMipCount; i++) {
|
|
1333
|
+
this.bloomDownMipViews.push(this.bloomDownTexture.createView({ baseMipLevel: i, mipLevelCount: 1 }));
|
|
1334
|
+
}
|
|
1335
|
+
this.bloomUpMipViews = [];
|
|
1336
|
+
const upLevels = Math.max(1, this.bloomMipCount - 1);
|
|
1337
|
+
for (let i = 0; i < upLevels; i++) {
|
|
1338
|
+
this.bloomUpMipViews.push(this.bloomUpTexture.createView({ baseMipLevel: i, mipLevelCount: 1 }));
|
|
1339
|
+
}
|
|
770
1340
|
this.depthTexture = this.device.createTexture({
|
|
771
1341
|
label: "depth texture",
|
|
772
1342
|
size: [width, height],
|
|
@@ -777,7 +1347,7 @@ export class Engine {
|
|
|
777
1347
|
const depthTextureView = this.depthTexture.createView();
|
|
778
1348
|
const colorAttachment = {
|
|
779
1349
|
view: this.multisampleTexture.createView(),
|
|
780
|
-
resolveTarget: this.
|
|
1350
|
+
resolveTarget: this.hdrResolveTexture.createView(),
|
|
781
1351
|
clearValue: { r: 0, g: 0, b: 0, a: 0 },
|
|
782
1352
|
loadOp: "clear",
|
|
783
1353
|
storeOp: "store",
|
|
@@ -795,6 +1365,72 @@ export class Engine {
|
|
|
795
1365
|
stencilStoreOp: "discard",
|
|
796
1366
|
},
|
|
797
1367
|
};
|
|
1368
|
+
// Composite pass descriptor (color attachment view patched per-frame to current swapchain).
|
|
1369
|
+
this.compositePassDescriptor = {
|
|
1370
|
+
label: "composite pass",
|
|
1371
|
+
colorAttachments: [
|
|
1372
|
+
{
|
|
1373
|
+
view: undefined,
|
|
1374
|
+
clearValue: { r: 0, g: 0, b: 0, a: 0 },
|
|
1375
|
+
loadOp: "clear",
|
|
1376
|
+
storeOp: "store",
|
|
1377
|
+
},
|
|
1378
|
+
],
|
|
1379
|
+
};
|
|
1380
|
+
this.writeBloomUniforms();
|
|
1381
|
+
if (this.compositeBindGroupLayout && this.bloomBlitBindGroupLayout) {
|
|
1382
|
+
// Blit: reads HDR resolve texture (full-res), writes bloomDown mip 0.
|
|
1383
|
+
this.bloomBlitBindGroup = this.device.createBindGroup({
|
|
1384
|
+
label: "bloom blit bind group",
|
|
1385
|
+
layout: this.bloomBlitBindGroupLayout,
|
|
1386
|
+
entries: [
|
|
1387
|
+
{ binding: 0, resource: this.hdrResolveTexture.createView() },
|
|
1388
|
+
{ binding: 1, resource: { buffer: this.bloomBlitUniformBuffer } },
|
|
1389
|
+
],
|
|
1390
|
+
});
|
|
1391
|
+
// Downsample[i] reads bloomDown mip (i-1), writes bloomDown mip i. i ∈ [1..N-1].
|
|
1392
|
+
this.bloomDownsampleBindGroups = [];
|
|
1393
|
+
for (let i = 1; i < this.bloomMipCount; i++) {
|
|
1394
|
+
this.bloomDownsampleBindGroups.push(this.device.createBindGroup({
|
|
1395
|
+
label: `bloom downsample ${i}`,
|
|
1396
|
+
layout: this.bloomDownsampleBindGroupLayout,
|
|
1397
|
+
entries: [
|
|
1398
|
+
{ binding: 0, resource: this.bloomDownMipViews[i - 1] },
|
|
1399
|
+
{ binding: 1, resource: this.bloomSampler },
|
|
1400
|
+
],
|
|
1401
|
+
}));
|
|
1402
|
+
}
|
|
1403
|
+
// Upsample[i] writes bloomUp mip i. Coarsest step reads bloomDown[N-1] (no prior up yet);
|
|
1404
|
+
// subsequent steps read bloomUp[i+1]. Both read bloomDown[i] as the base (additive combine).
|
|
1405
|
+
this.bloomUpsampleBindGroups = [];
|
|
1406
|
+
const topIdx = this.bloomMipCount - 2;
|
|
1407
|
+
for (let i = topIdx; i >= 0; i--) {
|
|
1408
|
+
const srcView = i === topIdx ? this.bloomDownMipViews[this.bloomMipCount - 1] : this.bloomUpMipViews[i + 1];
|
|
1409
|
+
this.bloomUpsampleBindGroups.push(this.device.createBindGroup({
|
|
1410
|
+
label: `bloom upsample ${i}`,
|
|
1411
|
+
layout: this.bloomUpsampleBindGroupLayout,
|
|
1412
|
+
entries: [
|
|
1413
|
+
{ binding: 0, resource: srcView },
|
|
1414
|
+
{ binding: 1, resource: this.bloomDownMipViews[i] },
|
|
1415
|
+
{ binding: 2, resource: this.bloomSampler },
|
|
1416
|
+
{ binding: 3, resource: { buffer: this.bloomUpsampleUniformBuffer } },
|
|
1417
|
+
],
|
|
1418
|
+
}));
|
|
1419
|
+
}
|
|
1420
|
+
// Composite reads bloomUp mip 0 (full pyramid collapsed); fallback to bloomDown mip 0 if no upsample level.
|
|
1421
|
+
const compositeBloomView = this.bloomMipCount > 1 ? this.bloomUpMipViews[0] : this.bloomDownMipViews[0];
|
|
1422
|
+
this.compositeBindGroup = this.device.createBindGroup({
|
|
1423
|
+
label: "composite bind group",
|
|
1424
|
+
layout: this.compositeBindGroupLayout,
|
|
1425
|
+
entries: [
|
|
1426
|
+
{ binding: 0, resource: this.hdrResolveTexture.createView() },
|
|
1427
|
+
{ binding: 1, resource: compositeBloomView },
|
|
1428
|
+
{ binding: 2, resource: this.bloomSampler },
|
|
1429
|
+
{ binding: 3, resource: { buffer: this.compositeUniformBuffer } },
|
|
1430
|
+
],
|
|
1431
|
+
});
|
|
1432
|
+
}
|
|
1433
|
+
this.writeCompositeViewUniforms();
|
|
798
1434
|
this.camera.aspect = width / height;
|
|
799
1435
|
if (this.onRaycast) {
|
|
800
1436
|
this.pickTexture = this.device.createTexture({
|
|
@@ -819,7 +1455,7 @@ export class Engine {
|
|
|
819
1455
|
size: 40 * 4,
|
|
820
1456
|
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
|
|
821
1457
|
});
|
|
822
|
-
this.camera = new Camera(Math.PI, Math.PI / 2.5, this.
|
|
1458
|
+
this.camera = new Camera(Math.PI, Math.PI / 2.5, this.cameraConfig.distance, this.cameraConfig.target, this.cameraConfig.fov);
|
|
823
1459
|
this.camera.aspect = this.canvas.width / this.canvas.height;
|
|
824
1460
|
this.camera.attachControl(this.canvas);
|
|
825
1461
|
}
|
|
@@ -853,58 +1489,100 @@ export class Engine {
|
|
|
853
1489
|
this.cameraTargetOffset.y = offset?.y ?? 0;
|
|
854
1490
|
this.cameraTargetOffset.z = offset?.z ?? 0;
|
|
855
1491
|
}
|
|
856
|
-
getCameraDistance() {
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
1492
|
+
getCameraDistance() {
|
|
1493
|
+
return this.camera.radius;
|
|
1494
|
+
}
|
|
1495
|
+
setCameraDistance(d) {
|
|
1496
|
+
this.camera.radius = d;
|
|
1497
|
+
}
|
|
1498
|
+
getCameraAlpha() {
|
|
1499
|
+
return this.camera.alpha;
|
|
1500
|
+
}
|
|
1501
|
+
setCameraAlpha(a) {
|
|
1502
|
+
this.camera.alpha = a;
|
|
1503
|
+
}
|
|
1504
|
+
getCameraBeta() {
|
|
1505
|
+
return this.camera.beta;
|
|
1506
|
+
}
|
|
1507
|
+
setCameraBeta(b) {
|
|
1508
|
+
this.camera.beta = b;
|
|
1509
|
+
}
|
|
862
1510
|
// Step 5: Create lighting buffers
|
|
863
1511
|
setupLighting() {
|
|
864
1512
|
this.lightUniformBuffer = this.device.createBuffer({
|
|
865
1513
|
label: "light uniforms",
|
|
866
|
-
size: 64 * 4, //
|
|
1514
|
+
size: 64 * 4, // ambientColor vec4f (4) + 4 lights * 2 vec4f each (32) = 36 f32 padded to 64
|
|
867
1515
|
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
|
|
868
1516
|
});
|
|
869
|
-
// Initialize light buffer to zeros
|
|
870
1517
|
this.lightData.fill(0);
|
|
871
1518
|
this.lightCount = 0;
|
|
872
|
-
this.
|
|
873
|
-
this.
|
|
874
|
-
}
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
1519
|
+
this.writeWorld();
|
|
1520
|
+
this.writeSun(0);
|
|
1521
|
+
}
|
|
1522
|
+
/**
|
|
1523
|
+
* Write world ambient. For a uniform-radiance world, hemispherical irradiance
|
|
1524
|
+
* is E = π·L and a Lambertian BRDF reflects (albedo/π)·E = albedo·L, so the
|
|
1525
|
+
* shader's ambient uniform is just `world.color × world.strength` — no /π.
|
|
1526
|
+
*/
|
|
1527
|
+
writeWorld() {
|
|
1528
|
+
const s = this.world.strength;
|
|
1529
|
+
this.lightData[0] = this.world.color.x * s;
|
|
1530
|
+
this.lightData[1] = this.world.color.y * s;
|
|
1531
|
+
this.lightData[2] = this.world.color.z * s;
|
|
1532
|
+
this.lightData[3] = 0;
|
|
881
1533
|
this.updateLightBuffer();
|
|
882
1534
|
}
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
const
|
|
888
|
-
|
|
889
|
-
this.lightData[
|
|
890
|
-
this.lightData[
|
|
891
|
-
this.lightData[
|
|
892
|
-
this.lightData[
|
|
893
|
-
this.lightData[
|
|
894
|
-
this.lightData[
|
|
895
|
-
this.lightData[
|
|
896
|
-
this.
|
|
1535
|
+
/** Write sun lamp into light slot `index` (0..3). Layout mirrors the WGSL struct. */
|
|
1536
|
+
writeSun(index) {
|
|
1537
|
+
if (index < 0 || index >= 4)
|
|
1538
|
+
return;
|
|
1539
|
+
const normalized = this.sun.direction.normalize();
|
|
1540
|
+
const base = 4 + index * 8; // 8 floats per light (direction vec4, color vec4)
|
|
1541
|
+
this.lightData[base] = normalized.x;
|
|
1542
|
+
this.lightData[base + 1] = normalized.y;
|
|
1543
|
+
this.lightData[base + 2] = normalized.z;
|
|
1544
|
+
this.lightData[base + 3] = 0;
|
|
1545
|
+
this.lightData[base + 4] = this.sun.color.x;
|
|
1546
|
+
this.lightData[base + 5] = this.sun.color.y;
|
|
1547
|
+
this.lightData[base + 6] = this.sun.color.z;
|
|
1548
|
+
this.lightData[base + 7] = this.sun.strength;
|
|
1549
|
+
if (index >= this.lightCount)
|
|
1550
|
+
this.lightCount = index + 1;
|
|
897
1551
|
this.updateLightBuffer();
|
|
898
|
-
|
|
1552
|
+
}
|
|
1553
|
+
/** Update the world environment (Blender: World Background). Ambient recomputes immediately. */
|
|
1554
|
+
setWorld(options) {
|
|
1555
|
+
if (options.color)
|
|
1556
|
+
this.world.color = options.color;
|
|
1557
|
+
if (options.strength !== undefined)
|
|
1558
|
+
this.world.strength = options.strength;
|
|
1559
|
+
this.writeWorld();
|
|
1560
|
+
}
|
|
1561
|
+
/** Update the sun lamp (Blender: Light > Sun). Direction change marks shadow VP dirty. */
|
|
1562
|
+
setSun(options) {
|
|
1563
|
+
if (options.color)
|
|
1564
|
+
this.sun.color = options.color;
|
|
1565
|
+
if (options.strength !== undefined)
|
|
1566
|
+
this.sun.strength = options.strength;
|
|
1567
|
+
if (options.direction) {
|
|
1568
|
+
this.sun.direction = options.direction;
|
|
1569
|
+
this.shadowLightVPDirty = true;
|
|
1570
|
+
}
|
|
1571
|
+
this.writeSun(0);
|
|
1572
|
+
}
|
|
1573
|
+
getWorld() {
|
|
1574
|
+
return this.world;
|
|
1575
|
+
}
|
|
1576
|
+
getSun() {
|
|
1577
|
+
return this.sun;
|
|
899
1578
|
}
|
|
900
1579
|
addGround(options) {
|
|
901
1580
|
const opts = {
|
|
902
1581
|
width: 160,
|
|
903
1582
|
height: 160,
|
|
904
|
-
diffuseColor: new Vec3(0.
|
|
1583
|
+
diffuseColor: new Vec3(0.9, 0.1, 1.0),
|
|
905
1584
|
fadeStart: 10.0,
|
|
906
1585
|
fadeEnd: 80.0,
|
|
907
|
-
shadowMapSize: 4096,
|
|
908
1586
|
shadowStrength: 1.0,
|
|
909
1587
|
gridSpacing: 4.2,
|
|
910
1588
|
gridLineWidth: 0.012,
|
|
@@ -922,6 +1600,7 @@ export class Engine {
|
|
|
922
1600
|
firstIndex: 0,
|
|
923
1601
|
bindGroup: this.groundShadowBindGroup,
|
|
924
1602
|
materialName: "Ground",
|
|
1603
|
+
preset: "cloth_rough",
|
|
925
1604
|
};
|
|
926
1605
|
}
|
|
927
1606
|
updateLightBuffer() {
|
|
@@ -965,28 +1644,55 @@ export class Engine {
|
|
|
965
1644
|
this.resizeObserver = null;
|
|
966
1645
|
}
|
|
967
1646
|
}
|
|
968
|
-
async loadModel(nameOrPath,
|
|
969
|
-
|
|
970
|
-
|
|
1647
|
+
async loadModel(nameOrPath, pathOrOptions) {
|
|
1648
|
+
if (pathOrOptions !== undefined && typeof pathOrOptions === "object" && "files" in pathOrOptions) {
|
|
1649
|
+
const name = nameOrPath;
|
|
1650
|
+
const pmxFile = pathOrOptions.pmxFile ?? findFirstPmxFileInList(pathOrOptions.files);
|
|
1651
|
+
if (!pmxFile)
|
|
1652
|
+
throw new Error("No .pmx file found in the selected folder");
|
|
1653
|
+
const map = fileListToMap(pathOrOptions.files);
|
|
1654
|
+
const pmxKey = normalizeAssetPath(pmxFile.webkitRelativePath ?? pmxFile.name);
|
|
1655
|
+
const reader = createFileMapAssetReader(map);
|
|
1656
|
+
const model = await PmxLoader.loadFromReader(reader, pmxKey);
|
|
1657
|
+
model.setName(name);
|
|
1658
|
+
await this.addModel(model, pmxKey, name, reader);
|
|
1659
|
+
return model;
|
|
1660
|
+
}
|
|
1661
|
+
const pmxPath = pathOrOptions === undefined ? nameOrPath : pathOrOptions;
|
|
1662
|
+
const name = pathOrOptions === undefined ? "model_" + this._nextDefaultModelId++ : nameOrPath;
|
|
971
1663
|
const model = await PmxLoader.load(pmxPath);
|
|
972
1664
|
model.setName(name);
|
|
973
1665
|
await this.addModel(model, pmxPath, name);
|
|
974
1666
|
return model;
|
|
975
1667
|
}
|
|
976
|
-
async addModel(model, pmxPath, name) {
|
|
1668
|
+
async addModel(model, pmxPath, name, assetReader) {
|
|
977
1669
|
const requested = name ?? model.name;
|
|
978
1670
|
let key = requested;
|
|
979
1671
|
let n = 1;
|
|
980
1672
|
while (this.modelInstances.has(key)) {
|
|
981
1673
|
key = `${requested}_${n++}`;
|
|
982
1674
|
}
|
|
983
|
-
const
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
await this.setupModelInstance(key, model, basePath);
|
|
1675
|
+
const reader = assetReader ?? createFetchAssetReader();
|
|
1676
|
+
const basePath = deriveBasePathFromPmxPath(pmxPath);
|
|
1677
|
+
model.setAssetContext(reader, basePath);
|
|
1678
|
+
await this.setupModelInstance(key, model, basePath, reader);
|
|
987
1679
|
return key;
|
|
988
1680
|
}
|
|
989
1681
|
removeModel(name) {
|
|
1682
|
+
const inst = this.modelInstances.get(name);
|
|
1683
|
+
if (!inst)
|
|
1684
|
+
return;
|
|
1685
|
+
inst.model.stopAnimation();
|
|
1686
|
+
for (const path of inst.textureCacheKeys) {
|
|
1687
|
+
const tex = this.textureCache.get(path);
|
|
1688
|
+
if (tex) {
|
|
1689
|
+
tex.destroy();
|
|
1690
|
+
this.textureCache.delete(path);
|
|
1691
|
+
}
|
|
1692
|
+
}
|
|
1693
|
+
for (const buf of inst.gpuBuffers) {
|
|
1694
|
+
buf.destroy();
|
|
1695
|
+
}
|
|
990
1696
|
this.modelInstances.delete(name);
|
|
991
1697
|
}
|
|
992
1698
|
getModelNames() {
|
|
@@ -1011,6 +1717,15 @@ export class Engine {
|
|
|
1011
1717
|
}
|
|
1012
1718
|
}
|
|
1013
1719
|
}
|
|
1720
|
+
setMaterialPresets(modelName, presets) {
|
|
1721
|
+
const inst = this.modelInstances.get(modelName);
|
|
1722
|
+
if (!inst)
|
|
1723
|
+
return;
|
|
1724
|
+
inst.materialPresets = presets;
|
|
1725
|
+
for (const dc of inst.drawCalls) {
|
|
1726
|
+
dc.preset = resolvePreset(dc.materialName, presets);
|
|
1727
|
+
}
|
|
1728
|
+
}
|
|
1014
1729
|
setMaterialVisible(modelName, materialName, visible) {
|
|
1015
1730
|
const inst = this.modelInstances.get(modelName);
|
|
1016
1731
|
if (!inst)
|
|
@@ -1068,7 +1783,7 @@ export class Engine {
|
|
|
1068
1783
|
this.device.queue.writeBuffer(inst.vertexBuffer, 0, vertices);
|
|
1069
1784
|
inst.vertexBufferNeedsUpdate = false;
|
|
1070
1785
|
}
|
|
1071
|
-
async setupModelInstance(name, model, basePath) {
|
|
1786
|
+
async setupModelInstance(name, model, basePath, assetReader) {
|
|
1072
1787
|
const vertices = model.getVertices();
|
|
1073
1788
|
const skinning = model.getSkinning();
|
|
1074
1789
|
const skeleton = model.getSkeleton();
|
|
@@ -1119,21 +1834,21 @@ export class Engine {
|
|
|
1119
1834
|
const mainPerInstanceBindGroup = this.device.createBindGroup({
|
|
1120
1835
|
label: `${name}: main per-instance bind group`,
|
|
1121
1836
|
layout: this.mainPerInstanceBindGroupLayout,
|
|
1122
|
-
entries: [
|
|
1123
|
-
{ binding: 0, resource: { buffer: skinMatrixBuffer } },
|
|
1124
|
-
],
|
|
1837
|
+
entries: [{ binding: 0, resource: { buffer: skinMatrixBuffer } }],
|
|
1125
1838
|
});
|
|
1126
1839
|
const pickPerInstanceBindGroup = this.device.createBindGroup({
|
|
1127
1840
|
label: `${name}: pick per-instance bind group`,
|
|
1128
1841
|
layout: this.pickPerInstanceBindGroupLayout,
|
|
1129
|
-
entries: [
|
|
1130
|
-
{ binding: 0, resource: { buffer: skinMatrixBuffer } },
|
|
1131
|
-
],
|
|
1842
|
+
entries: [{ binding: 0, resource: { buffer: skinMatrixBuffer } }],
|
|
1132
1843
|
});
|
|
1844
|
+
const gpuBuffers = [vertexBuffer, indexBuffer, jointsBuffer, weightsBuffer, skinMatrixBuffer];
|
|
1133
1845
|
const inst = {
|
|
1134
1846
|
name,
|
|
1135
1847
|
model,
|
|
1136
1848
|
basePath,
|
|
1849
|
+
assetReader,
|
|
1850
|
+
gpuBuffers,
|
|
1851
|
+
textureCacheKeys: [],
|
|
1137
1852
|
vertexBuffer,
|
|
1138
1853
|
indexBuffer,
|
|
1139
1854
|
jointsBuffer,
|
|
@@ -1146,6 +1861,7 @@ export class Engine {
|
|
|
1146
1861
|
pickPerInstanceBindGroup,
|
|
1147
1862
|
pickDrawCalls: [],
|
|
1148
1863
|
hiddenMaterials: new Set(),
|
|
1864
|
+
materialPresets: undefined,
|
|
1149
1865
|
physics,
|
|
1150
1866
|
vertexBufferNeedsUpdate: false,
|
|
1151
1867
|
};
|
|
@@ -1217,15 +1933,8 @@ export class Engine {
|
|
|
1217
1933
|
this.device.queue.writeBuffer(this.groundIndexBuffer, 0, indices);
|
|
1218
1934
|
}
|
|
1219
1935
|
createShadowGroundResources(opts) {
|
|
1220
|
-
const {
|
|
1221
|
-
|
|
1222
|
-
label: "shadow map",
|
|
1223
|
-
size: [shadowMapSize, shadowMapSize],
|
|
1224
|
-
format: "depth32float",
|
|
1225
|
-
usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING,
|
|
1226
|
-
});
|
|
1227
|
-
this.shadowMapDepthView = this.shadowMapTexture.createView();
|
|
1228
|
-
// Layout: diffuseColor(3f) fadeStart(1f) | fadeEnd(1f) shadowStrength(1f) pcfTexel(1f) gridSpacing(1f) | gridLineWidth(1f) gridOpacity(1f) noiseStrength(1f) _pad(1f) | gridColor(3f) _pad2(1f)
|
|
1936
|
+
const { diffuseColor, fadeStart, fadeEnd, shadowStrength, gridSpacing, gridLineWidth, gridLineOpacity, gridLineColor, noiseStrength, } = opts;
|
|
1937
|
+
// Shadow map is already created in setupPipelines()
|
|
1229
1938
|
const gb = new Float32Array(16);
|
|
1230
1939
|
gb[0] = diffuseColor.x;
|
|
1231
1940
|
gb[1] = diffuseColor.y;
|
|
@@ -1233,7 +1942,7 @@ export class Engine {
|
|
|
1233
1942
|
gb[3] = fadeStart;
|
|
1234
1943
|
gb[4] = fadeEnd;
|
|
1235
1944
|
gb[5] = shadowStrength;
|
|
1236
|
-
gb[6] = 1 /
|
|
1945
|
+
gb[6] = 1 / Engine.SHADOW_MAP_SIZE;
|
|
1237
1946
|
gb[7] = gridSpacing;
|
|
1238
1947
|
gb[8] = gridLineWidth;
|
|
1239
1948
|
gb[9] = gridLineOpacity;
|
|
@@ -1243,7 +1952,10 @@ export class Engine {
|
|
|
1243
1952
|
gb[13] = gridLineColor.y;
|
|
1244
1953
|
gb[14] = gridLineColor.z;
|
|
1245
1954
|
gb[15] = 0;
|
|
1246
|
-
this.groundShadowMaterialBuffer = this.device.createBuffer({
|
|
1955
|
+
this.groundShadowMaterialBuffer = this.device.createBuffer({
|
|
1956
|
+
size: gb.byteLength,
|
|
1957
|
+
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
|
|
1958
|
+
});
|
|
1247
1959
|
this.device.queue.writeBuffer(this.groundShadowMaterialBuffer, 0, gb);
|
|
1248
1960
|
this.groundShadowBindGroup = this.device.createBindGroup({
|
|
1249
1961
|
label: "ground shadow bind",
|
|
@@ -1262,7 +1974,7 @@ export class Engine {
|
|
|
1262
1974
|
if (!this.shadowLightVPDirty)
|
|
1263
1975
|
return;
|
|
1264
1976
|
this.shadowLightVPDirty = false;
|
|
1265
|
-
const dir = new Vec3(this.
|
|
1977
|
+
const dir = new Vec3(this.sun.direction.x, this.sun.direction.y, this.sun.direction.z);
|
|
1266
1978
|
dir.normalize();
|
|
1267
1979
|
const target = new Vec3(0, 11, 0);
|
|
1268
1980
|
const eye = new Vec3(target.x - dir.x * 72, target.y - dir.y * 72, target.z - dir.z * 72);
|
|
@@ -1285,8 +1997,8 @@ export class Engine {
|
|
|
1285
1997
|
const loadTextureByIndex = async (texIndex) => {
|
|
1286
1998
|
if (texIndex < 0 || texIndex >= textures.length)
|
|
1287
1999
|
return null;
|
|
1288
|
-
const
|
|
1289
|
-
return this.
|
|
2000
|
+
const logicalPath = joinAssetPath(inst.basePath, normalizeAssetPath(textures[texIndex].path));
|
|
2001
|
+
return this.createTextureFromLogicalPath(inst, logicalPath);
|
|
1290
2002
|
};
|
|
1291
2003
|
let currentIndexOffset = 0;
|
|
1292
2004
|
let materialId = 0;
|
|
@@ -1300,7 +2012,12 @@ export class Engine {
|
|
|
1300
2012
|
throw new Error(`Material "${mat.name}" has no diffuse texture`);
|
|
1301
2013
|
const materialAlpha = mat.diffuse[3];
|
|
1302
2014
|
const isTransparent = materialAlpha < 1.0 - 0.001;
|
|
1303
|
-
const materialUniformBuffer = this.createMaterialUniformBuffer(prefix + mat.name, materialAlpha, [
|
|
2015
|
+
const materialUniformBuffer = this.createMaterialUniformBuffer(prefix + mat.name, materialAlpha, [
|
|
2016
|
+
mat.diffuse[0],
|
|
2017
|
+
mat.diffuse[1],
|
|
2018
|
+
mat.diffuse[2],
|
|
2019
|
+
]);
|
|
2020
|
+
inst.gpuBuffers.push(materialUniformBuffer);
|
|
1304
2021
|
const textureView = diffuseTexture.createView();
|
|
1305
2022
|
const bindGroup = this.device.createBindGroup({
|
|
1306
2023
|
label: `${prefix}material: ${mat.name}`,
|
|
@@ -1311,26 +2028,47 @@ export class Engine {
|
|
|
1311
2028
|
],
|
|
1312
2029
|
});
|
|
1313
2030
|
const type = isTransparent ? "transparent" : "opaque";
|
|
1314
|
-
|
|
2031
|
+
const preset = resolvePreset(mat.name, inst.materialPresets);
|
|
2032
|
+
inst.drawCalls.push({
|
|
2033
|
+
type,
|
|
2034
|
+
count: indexCount,
|
|
2035
|
+
firstIndex: currentIndexOffset,
|
|
2036
|
+
bindGroup,
|
|
2037
|
+
materialName: mat.name,
|
|
2038
|
+
preset,
|
|
2039
|
+
});
|
|
1315
2040
|
if ((mat.edgeFlag & 0x10) !== 0 && mat.edgeSize > 0) {
|
|
1316
2041
|
const materialUniformData = new Float32Array([
|
|
1317
|
-
mat.edgeColor[0],
|
|
1318
|
-
mat.
|
|
2042
|
+
mat.edgeColor[0],
|
|
2043
|
+
mat.edgeColor[1],
|
|
2044
|
+
mat.edgeColor[2],
|
|
2045
|
+
mat.edgeColor[3],
|
|
2046
|
+
mat.edgeSize,
|
|
2047
|
+
0,
|
|
2048
|
+
0,
|
|
2049
|
+
0,
|
|
1319
2050
|
]);
|
|
1320
2051
|
const outlineUniformBuffer = this.createUniformBuffer(`${prefix}outline: ${mat.name}`, materialUniformData);
|
|
2052
|
+
inst.gpuBuffers.push(outlineUniformBuffer);
|
|
1321
2053
|
const outlineBindGroup = this.device.createBindGroup({
|
|
1322
2054
|
label: `${prefix}outline: ${mat.name}`,
|
|
1323
2055
|
layout: this.outlinePerMaterialBindGroupLayout,
|
|
1324
|
-
entries: [
|
|
1325
|
-
{ binding: 0, resource: { buffer: outlineUniformBuffer } },
|
|
1326
|
-
],
|
|
2056
|
+
entries: [{ binding: 0, resource: { buffer: outlineUniformBuffer } }],
|
|
1327
2057
|
});
|
|
1328
2058
|
const outlineType = isTransparent ? "transparent-outline" : "opaque-outline";
|
|
1329
|
-
inst.drawCalls.push({
|
|
2059
|
+
inst.drawCalls.push({
|
|
2060
|
+
type: outlineType,
|
|
2061
|
+
count: indexCount,
|
|
2062
|
+
firstIndex: currentIndexOffset,
|
|
2063
|
+
bindGroup: outlineBindGroup,
|
|
2064
|
+
materialName: mat.name,
|
|
2065
|
+
preset,
|
|
2066
|
+
});
|
|
1330
2067
|
}
|
|
1331
2068
|
if (this.onRaycast) {
|
|
1332
2069
|
const pickIdData = new Float32Array([modelId, materialId, 0, 0]);
|
|
1333
2070
|
const pickIdBuffer = this.createUniformBuffer(`${prefix}pick: ${mat.name}`, pickIdData);
|
|
2071
|
+
inst.gpuBuffers.push(pickIdBuffer);
|
|
1334
2072
|
const pickBindGroup = this.device.createBindGroup({
|
|
1335
2073
|
label: `${prefix}pick: ${mat.name}`,
|
|
1336
2074
|
layout: this.pickPerMaterialBindGroupLayout,
|
|
@@ -1345,18 +2083,13 @@ export class Engine {
|
|
|
1345
2083
|
inst.shadowDrawCalls.push(d);
|
|
1346
2084
|
}
|
|
1347
2085
|
}
|
|
1348
|
-
createMaterialUniformBuffer(label, alpha, diffuseColor
|
|
1349
|
-
|
|
1350
|
-
data
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
1.0, 1.0, 1.0, 0.0, // rimColor (vec3), _padding2
|
|
1356
|
-
diffuseColor[0], diffuseColor[1], diffuseColor[2], 0.0,
|
|
1357
|
-
ambientColor[0], ambientColor[1], ambientColor[2], 0.0,
|
|
1358
|
-
specularColor[0], specularColor[1], specularColor[2], 0.0,
|
|
1359
|
-
]);
|
|
2086
|
+
createMaterialUniformBuffer(label, alpha, diffuseColor) {
|
|
2087
|
+
// Matches WGSL `struct MaterialUniforms { diffuseColor: vec3f, alpha: f32 }` — 16 bytes.
|
|
2088
|
+
const data = new Float32Array(4);
|
|
2089
|
+
data[0] = diffuseColor[0];
|
|
2090
|
+
data[1] = diffuseColor[1];
|
|
2091
|
+
data[2] = diffuseColor[2];
|
|
2092
|
+
data[3] = alpha;
|
|
1360
2093
|
return this.createUniformBuffer(`material uniform: ${label}`, data);
|
|
1361
2094
|
}
|
|
1362
2095
|
createUniformBuffer(label, data) {
|
|
@@ -1371,31 +2104,30 @@ export class Engine {
|
|
|
1371
2104
|
shouldRenderDrawCall(inst, drawCall) {
|
|
1372
2105
|
return !inst.hiddenMaterials.has(drawCall.materialName);
|
|
1373
2106
|
}
|
|
1374
|
-
async
|
|
1375
|
-
const
|
|
2107
|
+
async createTextureFromLogicalPath(inst, logicalPath) {
|
|
2108
|
+
const cacheKey = logicalPath;
|
|
2109
|
+
const cached = this.textureCache.get(cacheKey);
|
|
1376
2110
|
if (cached) {
|
|
1377
2111
|
return cached;
|
|
1378
2112
|
}
|
|
1379
2113
|
try {
|
|
1380
|
-
const
|
|
1381
|
-
|
|
1382
|
-
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
|
1383
|
-
}
|
|
1384
|
-
const imageBitmap = await createImageBitmap(await response.blob(), {
|
|
2114
|
+
const buffer = await inst.assetReader.readBinary(logicalPath);
|
|
2115
|
+
const imageBitmap = await createImageBitmap(new Blob([buffer]), {
|
|
1385
2116
|
premultiplyAlpha: "none",
|
|
1386
2117
|
colorSpaceConversion: "none",
|
|
1387
2118
|
});
|
|
1388
2119
|
const texture = this.device.createTexture({
|
|
1389
|
-
label: `texture: ${
|
|
2120
|
+
label: `texture: ${cacheKey}`,
|
|
1390
2121
|
size: [imageBitmap.width, imageBitmap.height],
|
|
1391
|
-
format: "rgba8unorm",
|
|
2122
|
+
format: "rgba8unorm-srgb",
|
|
1392
2123
|
usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST | GPUTextureUsage.RENDER_ATTACHMENT,
|
|
1393
2124
|
});
|
|
1394
2125
|
this.device.queue.copyExternalImageToTexture({ source: imageBitmap }, { texture }, [
|
|
1395
2126
|
imageBitmap.width,
|
|
1396
2127
|
imageBitmap.height,
|
|
1397
2128
|
]);
|
|
1398
|
-
this.textureCache.set(
|
|
2129
|
+
this.textureCache.set(cacheKey, texture);
|
|
2130
|
+
inst.textureCacheKeys.push(cacheKey);
|
|
1399
2131
|
return texture;
|
|
1400
2132
|
}
|
|
1401
2133
|
catch {
|
|
@@ -1423,12 +2155,14 @@ export class Engine {
|
|
|
1423
2155
|
if (!this.pendingPick || !this.pickTexture || !this.pickDepthTexture)
|
|
1424
2156
|
return;
|
|
1425
2157
|
const pass = encoder.beginRenderPass({
|
|
1426
|
-
colorAttachments: [
|
|
2158
|
+
colorAttachments: [
|
|
2159
|
+
{
|
|
1427
2160
|
view: this.pickTexture.createView(),
|
|
1428
2161
|
clearValue: { r: 0, g: 0, b: 0, a: 0 },
|
|
1429
2162
|
loadOp: "clear",
|
|
1430
2163
|
storeOp: "store",
|
|
1431
|
-
}
|
|
2164
|
+
},
|
|
2165
|
+
],
|
|
1432
2166
|
depthStencilAttachment: {
|
|
1433
2167
|
view: this.pickDepthTexture.createView(),
|
|
1434
2168
|
depthClearValue: 1.0,
|
|
@@ -1503,7 +2237,6 @@ export class Engine {
|
|
|
1503
2237
|
const currentTime = performance.now();
|
|
1504
2238
|
const deltaTime = this.lastFrameTime > 0 ? (currentTime - this.lastFrameTime) / 1000 : 0.016;
|
|
1505
2239
|
this.lastFrameTime = currentTime;
|
|
1506
|
-
this.updateRenderTarget();
|
|
1507
2240
|
const hasModels = this.modelInstances.size > 0;
|
|
1508
2241
|
if (hasModels) {
|
|
1509
2242
|
this.updateInstances(deltaTime);
|
|
@@ -1520,10 +2253,9 @@ export class Engine {
|
|
|
1520
2253
|
}
|
|
1521
2254
|
}
|
|
1522
2255
|
this.updateCameraUniforms();
|
|
1523
|
-
|
|
1524
|
-
this.updateShadowLightVP();
|
|
2256
|
+
this.updateShadowLightVP();
|
|
1525
2257
|
const encoder = this.device.createCommandEncoder();
|
|
1526
|
-
if (hasModels
|
|
2258
|
+
if (hasModels) {
|
|
1527
2259
|
const sp = encoder.beginRenderPass({
|
|
1528
2260
|
colorAttachments: [],
|
|
1529
2261
|
depthStencilAttachment: {
|
|
@@ -1543,6 +2275,51 @@ export class Engine {
|
|
|
1543
2275
|
if (this.hasGround)
|
|
1544
2276
|
this.renderGround(pass);
|
|
1545
2277
|
pass.end();
|
|
2278
|
+
// Bloom pyramid (EEVEE 3.6):
|
|
2279
|
+
// 1. Blit: HDR → bloomDown[0] (Karis prefilter, half-res)
|
|
2280
|
+
// 2. Downsample: bloomDown[0] → bloomDown[1] → … → bloomDown[N-1] (13-tap)
|
|
2281
|
+
// 3. Upsample (top-down): bloomUp[N-2] = tent(bloomDown[N-1]) + bloomDown[N-2],
|
|
2282
|
+
// then bloomUp[i] = tent(bloomUp[i+1]) + bloomDown[i] until i=0 (9-tap tent)
|
|
2283
|
+
// Composite reads bloomUp[0] and adds tint * intensity * bloom before Filmic.
|
|
2284
|
+
if (this.bloomBlitBindGroup && this.compositeBindGroup && this.bloomMipCount > 0) {
|
|
2285
|
+
const bloomAtt = this.bloomPassDescriptor.colorAttachments;
|
|
2286
|
+
// 1. Blit
|
|
2287
|
+
bloomAtt[0].view = this.bloomDownMipViews[0];
|
|
2288
|
+
const pBlit = encoder.beginRenderPass(this.bloomPassDescriptor);
|
|
2289
|
+
pBlit.setPipeline(this.bloomBlitPipeline);
|
|
2290
|
+
pBlit.setBindGroup(0, this.bloomBlitBindGroup);
|
|
2291
|
+
pBlit.draw(3);
|
|
2292
|
+
pBlit.end();
|
|
2293
|
+
// 2. Downsample chain
|
|
2294
|
+
for (let i = 1; i < this.bloomMipCount; i++) {
|
|
2295
|
+
bloomAtt[0].view = this.bloomDownMipViews[i];
|
|
2296
|
+
const p = encoder.beginRenderPass(this.bloomPassDescriptor);
|
|
2297
|
+
p.setPipeline(this.bloomDownsamplePipeline);
|
|
2298
|
+
p.setBindGroup(0, this.bloomDownsampleBindGroups[i - 1]);
|
|
2299
|
+
p.draw(3);
|
|
2300
|
+
p.end();
|
|
2301
|
+
}
|
|
2302
|
+
// 3. Upsample chain (coarsest to finest; bindGroups[0] is the coarsest step)
|
|
2303
|
+
const upSteps = this.bloomUpsampleBindGroups.length;
|
|
2304
|
+
const topIdx = this.bloomMipCount - 2;
|
|
2305
|
+
for (let k = 0; k < upSteps; k++) {
|
|
2306
|
+
const levelIdx = topIdx - k; // writes bloomUp[levelIdx]
|
|
2307
|
+
bloomAtt[0].view = this.bloomUpMipViews[levelIdx];
|
|
2308
|
+
const p = encoder.beginRenderPass(this.bloomPassDescriptor);
|
|
2309
|
+
p.setPipeline(this.bloomUpsamplePipeline);
|
|
2310
|
+
p.setBindGroup(0, this.bloomUpsampleBindGroups[k]);
|
|
2311
|
+
p.draw(3);
|
|
2312
|
+
p.end();
|
|
2313
|
+
}
|
|
2314
|
+
}
|
|
2315
|
+
// Composite: HDR + bloom → Filmic tonemap → swapchain.
|
|
2316
|
+
const compositeAttachment = this.compositePassDescriptor.colorAttachments[0];
|
|
2317
|
+
compositeAttachment.view = this.context.getCurrentTexture().createView();
|
|
2318
|
+
const cpass = encoder.beginRenderPass(this.compositePassDescriptor);
|
|
2319
|
+
cpass.setPipeline(this.compositePipeline);
|
|
2320
|
+
cpass.setBindGroup(0, this.compositeBindGroup);
|
|
2321
|
+
cpass.draw(3);
|
|
2322
|
+
cpass.end();
|
|
1546
2323
|
const pick = this.pendingPick;
|
|
1547
2324
|
if (pick && hasModels)
|
|
1548
2325
|
this.renderPickPass(encoder);
|
|
@@ -1554,10 +2331,6 @@ export class Engine {
|
|
|
1554
2331
|
}
|
|
1555
2332
|
this.updateStats(performance.now() - currentTime);
|
|
1556
2333
|
}
|
|
1557
|
-
updateRenderTarget() {
|
|
1558
|
-
const colorAttachment = this.renderPassDescriptor.colorAttachments[0];
|
|
1559
|
-
colorAttachment.resolveTarget = this.context.getCurrentTexture().createView();
|
|
1560
|
-
}
|
|
1561
2334
|
drawInstanceShadow(sp, inst) {
|
|
1562
2335
|
sp.setBindGroup(0, inst.shadowBindGroup);
|
|
1563
2336
|
sp.setVertexBuffer(0, inst.vertexBuffer);
|
|
@@ -1569,39 +2342,87 @@ export class Engine {
|
|
|
1569
2342
|
sp.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0);
|
|
1570
2343
|
}
|
|
1571
2344
|
}
|
|
1572
|
-
|
|
1573
|
-
|
|
2345
|
+
pipelineForPreset(preset) {
|
|
2346
|
+
if (preset === "face")
|
|
2347
|
+
return this.facePipeline;
|
|
2348
|
+
if (preset === "hair")
|
|
2349
|
+
return this.hairPipeline;
|
|
2350
|
+
if (preset === "cloth_smooth")
|
|
2351
|
+
return this.clothSmoothPipeline;
|
|
2352
|
+
if (preset === "cloth_rough")
|
|
2353
|
+
return this.clothRoughPipeline;
|
|
2354
|
+
if (preset === "metal")
|
|
2355
|
+
return this.metalPipeline;
|
|
2356
|
+
if (preset === "body")
|
|
2357
|
+
return this.bodyPipeline;
|
|
2358
|
+
if (preset === "eye")
|
|
2359
|
+
return this.eyePipeline;
|
|
2360
|
+
if (preset === "stockings")
|
|
2361
|
+
return this.stockingsPipeline;
|
|
2362
|
+
return this.modelPipeline;
|
|
2363
|
+
}
|
|
2364
|
+
/**
|
|
2365
|
+
* Draw every material of a given type (`opaque` or `transparent`) using the main
|
|
2366
|
+
* pipeline(s). Binds the per-frame and per-instance groups once at the top of the
|
|
2367
|
+
* batch, then issues one draw per material. Early-outs if nothing to draw so we
|
|
2368
|
+
* don't waste bindings when a model has no transparents, etc.
|
|
2369
|
+
*/
|
|
2370
|
+
drawMaterials(pass, inst, type) {
|
|
2371
|
+
let currentPipeline = null;
|
|
2372
|
+
let bound = false;
|
|
1574
2373
|
for (const draw of inst.drawCalls) {
|
|
1575
|
-
if (draw.type
|
|
1576
|
-
|
|
1577
|
-
|
|
2374
|
+
if (draw.type !== type || !this.shouldRenderDrawCall(inst, draw))
|
|
2375
|
+
continue;
|
|
2376
|
+
if (!bound) {
|
|
2377
|
+
pass.setBindGroup(0, this.perFrameBindGroup);
|
|
2378
|
+
pass.setBindGroup(1, inst.mainPerInstanceBindGroup);
|
|
2379
|
+
bound = true;
|
|
2380
|
+
}
|
|
2381
|
+
const pipeline = this.pipelineForPreset(draw.preset);
|
|
2382
|
+
if (pipeline !== currentPipeline) {
|
|
2383
|
+
pass.setPipeline(pipeline);
|
|
2384
|
+
currentPipeline = pipeline;
|
|
1578
2385
|
}
|
|
2386
|
+
pass.setBindGroup(2, draw.bindGroup);
|
|
2387
|
+
pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0);
|
|
1579
2388
|
}
|
|
1580
2389
|
}
|
|
1581
|
-
|
|
1582
|
-
|
|
2390
|
+
/**
|
|
2391
|
+
* Draw every outline of a given type (`opaque-outline` or `transparent-outline`).
|
|
2392
|
+
* Uses its own pipeline layout (group 0 = camera-only, group 2 = edge uniforms), so
|
|
2393
|
+
* every batch binds its own groups from scratch — the next drawMaterials call will
|
|
2394
|
+
* rebind group 0/1 correctly if needed.
|
|
2395
|
+
*/
|
|
2396
|
+
drawOutlines(pass, inst, type) {
|
|
2397
|
+
let bound = false;
|
|
1583
2398
|
for (const draw of inst.drawCalls) {
|
|
1584
|
-
if (draw.type
|
|
1585
|
-
|
|
1586
|
-
|
|
2399
|
+
if (draw.type !== type || !this.shouldRenderDrawCall(inst, draw))
|
|
2400
|
+
continue;
|
|
2401
|
+
if (!bound) {
|
|
2402
|
+
pass.setPipeline(this.outlinePipeline);
|
|
2403
|
+
pass.setBindGroup(0, this.outlinePerFrameBindGroup);
|
|
2404
|
+
pass.setBindGroup(1, inst.mainPerInstanceBindGroup);
|
|
2405
|
+
bound = true;
|
|
1587
2406
|
}
|
|
2407
|
+
pass.setBindGroup(2, draw.bindGroup);
|
|
2408
|
+
pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0);
|
|
1588
2409
|
}
|
|
1589
2410
|
}
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
2411
|
+
/**
|
|
2412
|
+
* Main-pass render sequence for one model instance:
|
|
2413
|
+
* 1) opaque bodies → 2) opaque outlines → 3) transparents → 4) transparent outlines.
|
|
2414
|
+
* Each batch binds the groups it needs, so switching between main and outline
|
|
2415
|
+
* pipelines is self-contained (no cross-batch dependencies).
|
|
2416
|
+
*/
|
|
1594
2417
|
renderOneModel(pass, inst) {
|
|
1595
2418
|
pass.setVertexBuffer(0, inst.vertexBuffer);
|
|
1596
2419
|
pass.setVertexBuffer(1, inst.jointsBuffer);
|
|
1597
2420
|
pass.setVertexBuffer(2, inst.weightsBuffer);
|
|
1598
2421
|
pass.setIndexBuffer(inst.indexBuffer, "uint32");
|
|
1599
|
-
this.
|
|
1600
|
-
this.
|
|
1601
|
-
this.
|
|
1602
|
-
this.
|
|
1603
|
-
this.drawTransparent(pass, inst, this.modelPipeline);
|
|
1604
|
-
this.drawOutlines(pass, inst, true);
|
|
2422
|
+
this.drawMaterials(pass, inst, "opaque");
|
|
2423
|
+
this.drawOutlines(pass, inst, "opaque-outline");
|
|
2424
|
+
this.drawMaterials(pass, inst, "transparent");
|
|
2425
|
+
this.drawOutlines(pass, inst, "transparent-outline");
|
|
1605
2426
|
}
|
|
1606
2427
|
updateCameraUniforms() {
|
|
1607
2428
|
const viewMatrix = this.camera.getViewMatrix();
|
|
@@ -1620,18 +2441,6 @@ export class Engine {
|
|
|
1620
2441
|
this.device.queue.writeBuffer(inst.skinMatrixBuffer, 0, skinMatrices.buffer, skinMatrices.byteOffset, skinMatrices.byteLength);
|
|
1621
2442
|
});
|
|
1622
2443
|
}
|
|
1623
|
-
drawOutlines(pass, inst, transparent) {
|
|
1624
|
-
pass.setPipeline(this.outlinePipeline);
|
|
1625
|
-
pass.setBindGroup(0, this.outlinePerFrameBindGroup);
|
|
1626
|
-
pass.setBindGroup(1, inst.mainPerInstanceBindGroup);
|
|
1627
|
-
const outlineType = transparent ? "transparent-outline" : "opaque-outline";
|
|
1628
|
-
for (const draw of inst.drawCalls) {
|
|
1629
|
-
if (draw.type === outlineType && this.shouldRenderDrawCall(inst, draw)) {
|
|
1630
|
-
pass.setBindGroup(2, draw.bindGroup);
|
|
1631
|
-
pass.drawIndexed(draw.count, 1, draw.firstIndex, 0, 0);
|
|
1632
|
-
}
|
|
1633
|
-
}
|
|
1634
|
-
}
|
|
1635
2444
|
updateStats(frameTime) {
|
|
1636
2445
|
// Simplified frame time tracking - rolling average with fixed window
|
|
1637
2446
|
const maxSamples = 60;
|
|
@@ -1657,3 +2466,6 @@ export class Engine {
|
|
|
1657
2466
|
}
|
|
1658
2467
|
Engine.instance = null;
|
|
1659
2468
|
Engine.MULTISAMPLE_COUNT = 4;
|
|
2469
|
+
Engine.HDR_FORMAT = "rgba16float";
|
|
2470
|
+
Engine.BLOOM_MAX_LEVELS = 7;
|
|
2471
|
+
Engine.SHADOW_MAP_SIZE = 4096;
|