topazcube 0.1.33 → 0.1.35

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/dist/Renderer.cjs +2925 -281
  2. package/dist/Renderer.cjs.map +1 -1
  3. package/dist/Renderer.js +2925 -281
  4. package/dist/Renderer.js.map +1 -1
  5. package/package.json +1 -1
  6. package/src/renderer/DebugUI.js +176 -45
  7. package/src/renderer/Material.js +3 -0
  8. package/src/renderer/Pipeline.js +3 -1
  9. package/src/renderer/Renderer.js +185 -13
  10. package/src/renderer/core/AssetManager.js +40 -5
  11. package/src/renderer/core/CullingSystem.js +1 -0
  12. package/src/renderer/gltf.js +17 -0
  13. package/src/renderer/rendering/RenderGraph.js +224 -30
  14. package/src/renderer/rendering/passes/BloomPass.js +5 -2
  15. package/src/renderer/rendering/passes/CRTPass.js +724 -0
  16. package/src/renderer/rendering/passes/FogPass.js +26 -0
  17. package/src/renderer/rendering/passes/GBufferPass.js +31 -7
  18. package/src/renderer/rendering/passes/HiZPass.js +30 -0
  19. package/src/renderer/rendering/passes/LightingPass.js +14 -0
  20. package/src/renderer/rendering/passes/ParticlePass.js +10 -4
  21. package/src/renderer/rendering/passes/PostProcessPass.js +127 -4
  22. package/src/renderer/rendering/passes/SSGIPass.js +3 -2
  23. package/src/renderer/rendering/passes/SSGITilePass.js +14 -5
  24. package/src/renderer/rendering/passes/ShadowPass.js +265 -15
  25. package/src/renderer/rendering/passes/VolumetricFogPass.js +715 -0
  26. package/src/renderer/rendering/shaders/crt.wgsl +455 -0
  27. package/src/renderer/rendering/shaders/geometry.wgsl +36 -6
  28. package/src/renderer/rendering/shaders/particle_render.wgsl +153 -6
  29. package/src/renderer/rendering/shaders/postproc.wgsl +23 -2
  30. package/src/renderer/rendering/shaders/shadow.wgsl +42 -1
  31. package/src/renderer/rendering/shaders/volumetric_blur.wgsl +80 -0
  32. package/src/renderer/rendering/shaders/volumetric_composite.wgsl +80 -0
  33. package/src/renderer/rendering/shaders/volumetric_raymarch.wgsl +634 -0
  34. package/src/renderer/utils/Raycaster.js +761 -0
package/dist/Renderer.cjs CHANGED
@@ -770,6 +770,13 @@ function sphereInCascade(bsphere, cascadeMatrix) {
770
770
  if (clipZ + clipRadius < 0 || clipZ - clipRadius > 1) return false;
771
771
  return true;
772
772
  }
773
+ const BoundingSphere = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({
774
+ __proto__: null,
775
+ calculateBoundingSphere,
776
+ calculateShadowBoundingSphere,
777
+ sphereInCascade,
778
+ transformBoundingSphere
779
+ }, Symbol.toStringTag, { value: "Module" }));
773
780
  var _UID$2 = 30001;
774
781
  class Geometry {
775
782
  constructor(engine, attributes) {
@@ -1932,6 +1939,7 @@ class Material {
1932
1939
  this.luminanceToAlpha = false;
1933
1940
  this.forceEmissive = false;
1934
1941
  this.specularBoost = 0;
1942
+ this.doubleSided = false;
1935
1943
  }
1936
1944
  /**
1937
1945
  * Get textures array, substituting albedo for emission if forceEmissive is true
@@ -2667,6 +2675,13 @@ class ShadowPass extends BasePass {
2667
2675
  this.noiseAnimated = true;
2668
2676
  this.hizPass = null;
2669
2677
  this._meshBindGroups = /* @__PURE__ */ new WeakMap();
2678
+ this._cameraShadowBuffer = null;
2679
+ this._cameraShadowReadBuffer = null;
2680
+ this._cameraShadowPipeline = null;
2681
+ this._cameraShadowBindGroup = null;
2682
+ this._cameraShadowUniformBuffer = null;
2683
+ this._cameraInShadow = false;
2684
+ this._cameraShadowPending = false;
2670
2685
  }
2671
2686
  /**
2672
2687
  * Set the HiZ pass for occlusion culling of static meshes
@@ -2783,6 +2798,7 @@ class ShadowPass extends BasePass {
2783
2798
  });
2784
2799
  this._createPlaceholderTextures();
2785
2800
  await this._createPipeline();
2801
+ await this._createCameraShadowDetection();
2786
2802
  }
2787
2803
  async _createPipeline() {
2788
2804
  const { device } = this.engine;
@@ -3226,12 +3242,13 @@ class ShadowPass extends BasePass {
3226
3242
  * @param {mat4} cascadeMatrix - Cascade's view-projection matrix
3227
3243
  * @param {Array} lightDir - Normalized light direction (pointing to light)
3228
3244
  * @param {number} groundLevel - Ground plane Y coordinate
3245
+ * @param {Object|null} combinedBsphere - Combined bsphere for skinned models (optional)
3229
3246
  * @returns {{ data: Float32Array, count: number }}
3230
3247
  */
3231
- _buildCascadeFilteredInstances(geometry, cascadeMatrix, lightDir, groundLevel) {
3248
+ _buildCascadeFilteredInstances(geometry, cascadeMatrix, lightDir, groundLevel, combinedBsphere = null) {
3232
3249
  const instanceStride = 28;
3233
3250
  const visibleIndices = [];
3234
- const localBsphere = geometry.getBoundingSphere?.();
3251
+ const localBsphere = combinedBsphere || geometry.getBoundingSphere?.();
3235
3252
  for (let i = 0; i < geometry.instanceCount; i++) {
3236
3253
  const offset = i * instanceStride;
3237
3254
  let bsphere = {
@@ -3279,12 +3296,13 @@ class ShadowPass extends BasePass {
3279
3296
  * @param {Array} lightDir - Normalized light direction
3280
3297
  * @param {number} maxDistance - Max shadow distance (min of light radius and spotMaxDistance)
3281
3298
  * @param {number} coneAngle - Half-angle of spotlight cone in radians
3299
+ * @param {Object|null} combinedBsphere - Combined bsphere for skinned models (optional)
3282
3300
  * @returns {{ data: Float32Array, count: number }}
3283
3301
  */
3284
- _buildFilteredInstances(geometry, lightPos, lightDir, maxDistance, coneAngle) {
3302
+ _buildFilteredInstances(geometry, lightPos, lightDir, maxDistance, coneAngle, combinedBsphere = null) {
3285
3303
  const instanceStride = 28;
3286
3304
  const visibleIndices = [];
3287
- const localBsphere = geometry.getBoundingSphere?.();
3305
+ const localBsphere = combinedBsphere || geometry.getBoundingSphere?.();
3288
3306
  for (let i = 0; i < geometry.instanceCount; i++) {
3289
3307
  const offset = i * instanceStride;
3290
3308
  let bsphere = {
@@ -3504,6 +3522,10 @@ class ShadowPass extends BasePass {
3504
3522
  console.warn("ShadowPass: No pipeline or meshes", { pipeline: !!this.pipeline, meshes: !!meshes });
3505
3523
  return;
3506
3524
  }
3525
+ this._meshBindGroups = /* @__PURE__ */ new WeakMap();
3526
+ if (this._skinBindGroups) {
3527
+ this._skinBindGroups = /* @__PURE__ */ new WeakMap();
3528
+ }
3507
3529
  let shadowDrawCalls = 0;
3508
3530
  let shadowTriangles = 0;
3509
3531
  let shadowCulledInstances = 0;
@@ -3542,7 +3564,7 @@ class ShadowPass extends BasePass {
3542
3564
  visibleMeshes[name] = mesh;
3543
3565
  continue;
3544
3566
  }
3545
- const localBsphere = geometry.getBoundingSphere?.();
3567
+ const localBsphere = mesh.combinedBsphere || geometry.getBoundingSphere?.();
3546
3568
  if (!localBsphere || localBsphere.radius <= 0) {
3547
3569
  meshNoBsphere++;
3548
3570
  visibleMeshes[name] = mesh;
@@ -3551,23 +3573,28 @@ class ShadowPass extends BasePass {
3551
3573
  const matrix = geometry.instanceData?.subarray(0, 16);
3552
3574
  const worldBsphere = matrix ? transformBoundingSphere(localBsphere, matrix) : localBsphere;
3553
3575
  const shadowBsphere = mainLightEnabled ? calculateShadowBoundingSphere(worldBsphere, lightDir, groundLevel) : worldBsphere;
3554
- const dx = shadowBsphere.center[0] - camera.position[0];
3555
- const dy = shadowBsphere.center[1] - camera.position[1];
3556
- const dz = shadowBsphere.center[2] - camera.position[2];
3557
- const distance = Math.sqrt(dx * dx + dy * dy + dz * dz) - shadowBsphere.radius;
3576
+ const skinnedExpansion = this.engine?.settings?.shadow?.skinnedBsphereExpansion ?? 2;
3577
+ const cullBsphere = mesh.hasSkin ? {
3578
+ center: shadowBsphere.center,
3579
+ radius: shadowBsphere.radius * skinnedExpansion
3580
+ } : shadowBsphere;
3581
+ const dx = cullBsphere.center[0] - camera.position[0];
3582
+ const dy = cullBsphere.center[1] - camera.position[1];
3583
+ const dz = cullBsphere.center[2] - camera.position[2];
3584
+ const distance = Math.sqrt(dx * dx + dy * dy + dz * dz) - cullBsphere.radius;
3558
3585
  if (distance > shadowMaxDistance) {
3559
3586
  meshDistanceCulled++;
3560
3587
  continue;
3561
3588
  }
3562
3589
  if (shadowFrustumCullingEnabled && cameraFrustum) {
3563
- if (!cameraFrustum.testSpherePlanes(shadowBsphere)) {
3590
+ if (!cameraFrustum.testSpherePlanes(cullBsphere)) {
3564
3591
  meshFrustumCulled++;
3565
3592
  continue;
3566
3593
  }
3567
3594
  }
3568
3595
  if (shadowHiZEnabled && this.hizPass) {
3569
3596
  const occluded = this.hizPass.testSphereOcclusion(
3570
- shadowBsphere,
3597
+ cullBsphere,
3571
3598
  camera.viewProj,
3572
3599
  camera.near,
3573
3600
  camera.far,
@@ -3629,7 +3656,9 @@ class ShadowPass extends BasePass {
3629
3656
  geometry,
3630
3657
  this.cascadeMatrices[cascade],
3631
3658
  lightDir,
3632
- groundLevel
3659
+ groundLevel,
3660
+ mesh.combinedBsphere
3661
+ // Use combined bsphere for skinned models
3633
3662
  );
3634
3663
  if (filtered.count === 0) {
3635
3664
  cascadeCulledInstances += geometry.instanceCount;
@@ -3748,6 +3777,9 @@ class ShadowPass extends BasePass {
3748
3777
  this.cascadeMatricesData.set(this.cascadeMatrices[i], i * 16);
3749
3778
  }
3750
3779
  device.queue.writeBuffer(this.cascadeMatricesBuffer, 0, this.cascadeMatricesData);
3780
+ if (mainLightEnabled) {
3781
+ this._updateCameraShadowDetection(camera);
3782
+ }
3751
3783
  if (!mainLightEnabled) {
3752
3784
  for (const name in meshes) {
3753
3785
  const mesh = meshes[name];
@@ -3832,7 +3864,9 @@ class ShadowPass extends BasePass {
3832
3864
  lightPos,
3833
3865
  spotLightDir,
3834
3866
  spotShadowMaxDist,
3835
- coneAngle
3867
+ coneAngle,
3868
+ mesh.combinedBsphere
3869
+ // Use combined bsphere for skinned models
3836
3870
  );
3837
3871
  if (filtered.count === 0) {
3838
3872
  spotCulledInstances += geometry.instanceCount;
@@ -4061,6 +4095,184 @@ class ShadowPass extends BasePass {
4061
4095
  getLastSlotInfo() {
4062
4096
  return this.lastSlotInfo;
4063
4097
  }
4098
+ /**
4099
+ * Get cascade matrices as JavaScript arrays (for CPU-side calculations)
4100
+ */
4101
+ getCascadeMatrices() {
4102
+ return this.cascadeMatrices;
4103
+ }
4104
+ /**
4105
+ * Create resources for camera shadow detection
4106
+ * Uses a compute shader to sample shadow at camera position
4107
+ */
4108
+ async _createCameraShadowDetection() {
4109
+ const { device } = this.engine;
4110
+ this._cameraShadowUniformBuffer = device.createBuffer({
4111
+ label: "Camera Shadow Detection Uniforms",
4112
+ size: 256,
4113
+ // Aligned
4114
+ usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST
4115
+ });
4116
+ this._cameraShadowBuffer = device.createBuffer({
4117
+ label: "Camera Shadow Result",
4118
+ size: 4,
4119
+ usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_SRC
4120
+ });
4121
+ this._cameraShadowReadBuffer = device.createBuffer({
4122
+ label: "Camera Shadow Readback",
4123
+ size: 4,
4124
+ usage: GPUBufferUsage.MAP_READ | GPUBufferUsage.COPY_DST
4125
+ });
4126
+ const shaderModule = device.createShaderModule({
4127
+ label: "Camera Shadow Detection Shader",
4128
+ code: `
4129
+ struct Uniforms {
4130
+ cameraPosition: vec3f,
4131
+ _pad0: f32,
4132
+ cascadeMatrix0: mat4x4f,
4133
+ cascadeMatrix1: mat4x4f,
4134
+ cascadeMatrix2: mat4x4f,
4135
+ }
4136
+
4137
+ @group(0) @binding(0) var<uniform> uniforms: Uniforms;
4138
+ @group(0) @binding(1) var shadowMap: texture_depth_2d_array;
4139
+ @group(0) @binding(2) var shadowSampler: sampler_comparison;
4140
+ @group(0) @binding(3) var<storage, read_write> result: f32;
4141
+
4142
+ fn sampleShadowCascade(worldPos: vec3f, cascadeMatrix: mat4x4f, cascadeIndex: i32) -> f32 {
4143
+ let lightSpacePos = cascadeMatrix * vec4f(worldPos, 1.0);
4144
+ let projCoords = lightSpacePos.xyz / lightSpacePos.w;
4145
+
4146
+ // Convert to UV space
4147
+ let uv = vec2f(projCoords.x * 0.5 + 0.5, 0.5 - projCoords.y * 0.5);
4148
+
4149
+ // Check bounds
4150
+ if (uv.x < 0.01 || uv.x > 0.99 || uv.y < 0.01 || uv.y > 0.99 ||
4151
+ projCoords.z < 0.0 || projCoords.z > 1.0) {
4152
+ return -1.0; // Out of bounds, try next cascade
4153
+ }
4154
+
4155
+ let bias = 0.005;
4156
+ let depth = projCoords.z - bias;
4157
+ return textureSampleCompareLevel(shadowMap, shadowSampler, uv, cascadeIndex, depth);
4158
+ }
4159
+
4160
+ @compute @workgroup_size(1)
4161
+ fn main() {
4162
+ let pos = uniforms.cameraPosition;
4163
+
4164
+ // Sample multiple points around camera (5m sphere)
4165
+ var totalShadow = 0.0;
4166
+ var sampleCount = 0.0;
4167
+
4168
+ let offsets = array<vec3f, 7>(
4169
+ vec3f(0.0, 0.0, 0.0), // Center
4170
+ vec3f(0.0, 3.0, 0.0), // Above
4171
+ vec3f(0.0, -2.0, 0.0), // Below
4172
+ vec3f(4.0, 0.0, 0.0), // Right
4173
+ vec3f(-4.0, 0.0, 0.0), // Left
4174
+ vec3f(0.0, 0.0, 4.0), // Front
4175
+ vec3f(0.0, 0.0, -4.0), // Back
4176
+ );
4177
+
4178
+ for (var i = 0; i < 7; i++) {
4179
+ let samplePos = pos + offsets[i];
4180
+
4181
+ // Try cascade 0 first (closest)
4182
+ var shadow = sampleShadowCascade(samplePos, uniforms.cascadeMatrix0, 0);
4183
+ if (shadow < 0.0) {
4184
+ // Try cascade 1
4185
+ shadow = sampleShadowCascade(samplePos, uniforms.cascadeMatrix1, 1);
4186
+ }
4187
+ if (shadow < 0.0) {
4188
+ // Try cascade 2
4189
+ shadow = sampleShadowCascade(samplePos, uniforms.cascadeMatrix2, 2);
4190
+ }
4191
+
4192
+ if (shadow >= 0.0) {
4193
+ totalShadow += shadow;
4194
+ sampleCount += 1.0;
4195
+ }
4196
+ }
4197
+
4198
+ // Average shadow (0 = all in shadow, 1 = all lit)
4199
+ // If no valid samples, assume lit
4200
+ if (sampleCount > 0.0) {
4201
+ result = totalShadow / sampleCount;
4202
+ } else {
4203
+ result = 1.0;
4204
+ }
4205
+ }
4206
+ `
4207
+ });
4208
+ this._cameraShadowBGL = device.createBindGroupLayout({
4209
+ label: "Camera Shadow Detection BGL",
4210
+ entries: [
4211
+ { binding: 0, visibility: GPUShaderStage.COMPUTE, buffer: { type: "uniform" } },
4212
+ { binding: 1, visibility: GPUShaderStage.COMPUTE, texture: { sampleType: "depth", viewDimension: "2d-array" } },
4213
+ { binding: 2, visibility: GPUShaderStage.COMPUTE, sampler: { type: "comparison" } },
4214
+ { binding: 3, visibility: GPUShaderStage.COMPUTE, buffer: { type: "storage" } }
4215
+ ]
4216
+ });
4217
+ this._cameraShadowPipeline = await device.createComputePipelineAsync({
4218
+ label: "Camera Shadow Detection Pipeline",
4219
+ layout: device.createPipelineLayout({ bindGroupLayouts: [this._cameraShadowBGL] }),
4220
+ compute: { module: shaderModule, entryPoint: "main" }
4221
+ });
4222
+ }
4223
+ /**
4224
+ * Update camera shadow detection (called during execute)
4225
+ * Dispatches compute shader and starts async readback
4226
+ */
4227
+ _updateCameraShadowDetection(camera) {
4228
+ if (!this._cameraShadowPipeline || !this.directionalShadowMap) return;
4229
+ if (this._cameraShadowPending) return;
4230
+ const { device } = this.engine;
4231
+ const cameraPos = camera.position || [0, 0, 0];
4232
+ const data = new Float32Array(64);
4233
+ data[0] = cameraPos[0];
4234
+ data[1] = cameraPos[1];
4235
+ data[2] = cameraPos[2];
4236
+ data[3] = 0;
4237
+ if (this.cascadeMatrices[0]) data.set(this.cascadeMatrices[0], 4);
4238
+ if (this.cascadeMatrices[1]) data.set(this.cascadeMatrices[1], 20);
4239
+ if (this.cascadeMatrices[2]) data.set(this.cascadeMatrices[2], 36);
4240
+ device.queue.writeBuffer(this._cameraShadowUniformBuffer, 0, data);
4241
+ const bindGroup = device.createBindGroup({
4242
+ layout: this._cameraShadowBGL,
4243
+ entries: [
4244
+ { binding: 0, resource: { buffer: this._cameraShadowUniformBuffer } },
4245
+ { binding: 1, resource: this.directionalShadowMapView },
4246
+ { binding: 2, resource: this.shadowSampler },
4247
+ { binding: 3, resource: { buffer: this._cameraShadowBuffer } }
4248
+ ]
4249
+ });
4250
+ const encoder = device.createCommandEncoder({ label: "Camera Shadow Detection" });
4251
+ const pass = encoder.beginComputePass();
4252
+ pass.setPipeline(this._cameraShadowPipeline);
4253
+ pass.setBindGroup(0, bindGroup);
4254
+ pass.dispatchWorkgroups(1);
4255
+ pass.end();
4256
+ encoder.copyBufferToBuffer(this._cameraShadowBuffer, 0, this._cameraShadowReadBuffer, 0, 4);
4257
+ device.queue.submit([encoder.finish()]);
4258
+ this._cameraShadowPending = true;
4259
+ this._cameraShadowReadBuffer.mapAsync(GPUMapMode.READ).then(() => {
4260
+ const data2 = new Float32Array(this._cameraShadowReadBuffer.getMappedRange());
4261
+ const shadowValue = data2[0];
4262
+ this._cameraShadowReadBuffer.unmap();
4263
+ this._cameraShadowPending = false;
4264
+ this._cameraInShadow = shadowValue < 0.3;
4265
+ }).catch(() => {
4266
+ this._cameraShadowPending = false;
4267
+ });
4268
+ }
4269
+ /**
4270
+ * Check if camera is in shadow (uses async readback result from previous frames)
4271
+ * @returns {boolean} True if camera is mostly in shadow
4272
+ */
4273
+ isCameraInShadow() {
4274
+ return this._cameraInShadow;
4275
+ }
4064
4276
  }
4065
4277
  class ProbeCapture {
4066
4278
  constructor(engine) {
@@ -5718,8 +5930,10 @@ class Pipeline {
5718
5930
  // Optional tile light indices buffer for tiled lighting
5719
5931
  lightBuffer = null,
5720
5932
  // Optional light storage buffer for tiled lighting
5721
- noiseTexture = null
5933
+ noiseTexture = null,
5722
5934
  // Optional noise texture for alpha hashing
5935
+ doubleSided = false
5936
+ // Optional: disable backface culling for double-sided materials
5723
5937
  }) {
5724
5938
  let texture = textures[0];
5725
5939
  const { canvas, device, canvasFormat, options } = engine;
@@ -5781,7 +5995,7 @@ class Pipeline {
5781
5995
  geometry.vertexBufferLayout,
5782
5996
  geometry.instanceBufferLayout
5783
5997
  ];
5784
- pipelineDescriptor.primitive.cullMode = "back";
5998
+ pipelineDescriptor.primitive.cullMode = doubleSided ? "none" : "back";
5785
5999
  pipelineDescriptor.depthStencil = {
5786
6000
  depthWriteEnabled: true,
5787
6001
  depthCompare: "less",
@@ -6009,6 +6223,7 @@ class Pipeline {
6009
6223
  p.tileLightBuffer = tileLightBuffer;
6010
6224
  p.lightBuffer = lightBuffer;
6011
6225
  p.noiseTexture = noiseTexture;
6226
+ p.doubleSided = doubleSided;
6012
6227
  return p;
6013
6228
  }
6014
6229
  static async pipelineFromTextures(engine, pipelineDescriptor, label, textures, uniformBuffer, skin = null, noiseTexture = null) {
@@ -6183,7 +6398,7 @@ class Pipeline {
6183
6398
  }
6184
6399
  }
6185
6400
  }
6186
- var geometry_default = "struct VertexInput {\n @location(0) position: vec3f,\n @location(1) uv: vec2f,\n @location(2) normal: vec3f,\n @location(3) color: vec4f,\n @location(4) weights: vec4f,\n @location(5) joints: vec4u,\n}\n\nstruct VertexOutput {\n @invariant @builtin(position) position: vec4f, \n @location(0) worldPos: vec3f,\n @location(1) uv: vec2f,\n @location(2) normal: vec3f,\n @location(3) viewZ: f32, \n @location(4) currClipPos: vec4f, \n @location(5) prevClipPos: vec4f, \n @location(6) instanceColor: vec4f, \n @location(7) anchorY: f32, \n}\n\nstruct Uniforms {\n viewMatrix: mat4x4f,\n projectionMatrix: mat4x4f,\n prevViewProjMatrix: mat4x4f, \n mipBias: f32,\n skinEnabled: f32, \n numJoints: f32, \n near: f32, \n far: f32, \n jitterFadeDistance: f32, \n jitterOffset: vec2f, \n screenSize: vec2f, \n emissionFactor: vec4f,\n clipPlaneY: f32, \n clipPlaneEnabled: f32, \n clipPlaneDirection: f32, \n pixelRounding: f32, \n pixelExpansion: f32, \n positionRounding: f32, \n alphaHashEnabled: f32, \n alphaHashScale: f32, \n luminanceToAlpha: f32, \n noiseSize: f32, \n noiseOffsetX: f32, \n noiseOffsetY: f32, \n cameraPosition: vec3f, \n distanceFadeStart: f32, \n distanceFadeEnd: f32, \n \n billboardMode: f32, \n billboardCameraRight: vec3f, \n _pad1: f32,\n billboardCameraUp: vec3f, \n _pad2: f32,\n billboardCameraForward: vec3f, \n specularBoost: f32, \n}\n\nstruct InstanceData {\n modelMatrix: mat4x4f,\n posRadius: vec4f,\n}\n\n@group(0) @binding(0) var<uniform> uniforms: Uniforms;\n@group(0) @binding(1) var albedoTexture: texture_2d<f32>;\n@group(0) @binding(2) var albedoSampler: sampler;\n@group(0) @binding(3) var normalTexture: texture_2d<f32>;\n@group(0) @binding(4) var normalSampler: sampler;\n@group(0) @binding(5) var ambientTexture: texture_2d<f32>;\n@group(0) @binding(6) var ambientSampler: sampler;\n@group(0) @binding(7) var rmTexture: texture_2d<f32>;\n@group(0) @binding(8) var rmSampler: sampler;\n@group(0) @binding(9) var emissionTexture: texture_2d<f32>;\n@group(0) @binding(10) var emissionSampler: sampler;\n@group(0) @binding(11) var jointTexture: texture_2d<f32>;\n@group(0) @binding(12) var jointSampler: sampler;\n@group(0) @binding(13) var prevJointTexture: texture_2d<f32>; \n@group(0) @binding(14) var noiseTexture: texture_2d<f32>; \n\nfn sampleNoise(screenPos: vec2f) -> f32 {\n let noiseSize = i32(uniforms.noiseSize);\n let noiseOffsetX = i32(uniforms.noiseOffsetX * f32(noiseSize));\n let noiseOffsetY = i32(uniforms.noiseOffsetY * f32(noiseSize));\n\n let texCoord = vec2i(\n (i32(screenPos.x) + noiseOffsetX) % noiseSize,\n (i32(screenPos.y) + noiseOffsetY) % noiseSize\n );\n return textureLoad(noiseTexture, texCoord, 0).r;\n}\n\nfn getJointMatrix(jointIndex: u32) -> mat4x4f {\n let row = i32(jointIndex);\n let col0 = textureLoad(jointTexture, vec2i(0, row), 0);\n let col1 = textureLoad(jointTexture, vec2i(1, row), 0);\n let col2 = textureLoad(jointTexture, vec2i(2, row), 0);\n let col3 = textureLoad(jointTexture, vec2i(3, row), 0);\n return mat4x4f(col0, col1, col2, col3);\n}\n\nfn getPrevJointMatrix(jointIndex: u32) -> mat4x4f {\n let row = i32(jointIndex);\n let col0 = textureLoad(prevJointTexture, vec2i(0, row), 0);\n let col1 = textureLoad(prevJointTexture, vec2i(1, row), 0);\n let col2 = textureLoad(prevJointTexture, vec2i(2, row), 0);\n let col3 = textureLoad(prevJointTexture, vec2i(3, row), 0);\n return mat4x4f(col0, col1, col2, col3);\n}\n\nfn applySkinning(position: vec3f, joints: vec4u, weights: vec4f) -> vec3f {\n var skinnedPos = vec3f(0.0);\n\n let m0 = getJointMatrix(joints.x);\n let m1 = getJointMatrix(joints.y);\n let m2 = getJointMatrix(joints.z);\n let m3 = getJointMatrix(joints.w);\n\n skinnedPos += (m0 * vec4f(position, 1.0)).xyz * weights.x;\n skinnedPos += (m1 * vec4f(position, 1.0)).xyz * weights.y;\n skinnedPos += (m2 * vec4f(position, 1.0)).xyz * weights.z;\n skinnedPos += (m3 * vec4f(position, 1.0)).xyz * weights.w;\n\n return skinnedPos;\n}\n\nfn applySkinningPrev(position: vec3f, joints: vec4u, weights: vec4f) -> vec3f {\n var skinnedPos = vec3f(0.0);\n\n let m0 = getPrevJointMatrix(joints.x);\n let m1 = getPrevJointMatrix(joints.y);\n let m2 = getPrevJointMatrix(joints.z);\n let m3 = getPrevJointMatrix(joints.w);\n\n skinnedPos += (m0 * vec4f(position, 1.0)).xyz * weights.x;\n skinnedPos += (m1 * vec4f(position, 1.0)).xyz * weights.y;\n skinnedPos += (m2 * vec4f(position, 1.0)).xyz * weights.z;\n skinnedPos += (m3 * vec4f(position, 1.0)).xyz * weights.w;\n\n return skinnedPos;\n}\n\nfn applySkinningNormal(normal: vec3f, joints: vec4u, weights: vec4f) -> vec3f {\n var skinnedNormal = vec3f(0.0);\n\n let m0 = getJointMatrix(joints.x);\n let m1 = getJointMatrix(joints.y);\n let m2 = getJointMatrix(joints.z);\n let m3 = getJointMatrix(joints.w);\n\n \n let r0 = mat3x3f(m0[0].xyz, m0[1].xyz, m0[2].xyz);\n let r1 = mat3x3f(m1[0].xyz, m1[1].xyz, m1[2].xyz);\n let r2 = mat3x3f(m2[0].xyz, m2[1].xyz, m2[2].xyz);\n let r3 = mat3x3f(m3[0].xyz, m3[1].xyz, m3[2].xyz);\n\n skinnedNormal += (r0 * normal) * weights.x;\n skinnedNormal += (r1 * normal) * weights.y;\n skinnedNormal += (r2 * normal) * weights.z;\n skinnedNormal += (r3 * normal) * weights.w;\n\n return normalize(skinnedNormal);\n}\n\n@vertex\nfn vertexMain(\n input: VertexInput,\n @builtin(instance_index) instanceIdx : u32,\n @location(6) instanceModelMatrix0: vec4f,\n @location(7) instanceModelMatrix1: vec4f,\n @location(8) instanceModelMatrix2: vec4f,\n @location(9) instanceModelMatrix3: vec4f,\n @location(10) posRadius: vec4f,\n @location(11) uvTransform: vec4f, \n @location(12) instanceColor: vec4f, \n) -> VertexOutput {\n var output: VertexOutput;\n\n \n let instanceModelMatrix = mat4x4f(\n instanceModelMatrix0,\n instanceModelMatrix1,\n instanceModelMatrix2,\n instanceModelMatrix3\n );\n\n var localPos = input.position;\n var prevLocalPos = input.position; \n var localNormal = input.normal;\n var billboardWorldPos: vec3f;\n var useBillboardWorldPos = false;\n\n \n if (uniforms.billboardMode > 0.5) {\n \n let entityPos = instanceModelMatrix[3].xyz;\n\n \n let scaleX = length(instanceModelMatrix[0].xyz);\n let scaleY = length(instanceModelMatrix[1].xyz);\n\n if (uniforms.billboardMode < 1.5) {\n \n \n let toCamera = uniforms.cameraPosition - entityPos;\n let toCameraDist = length(toCamera);\n\n var right: vec3f;\n var up: vec3f;\n\n if (toCameraDist > 0.001) {\n let forward = toCamera / toCameraDist;\n \n right = cross(vec3f(0.0, 1.0, 0.0), forward);\n let rightLen = length(right);\n if (rightLen < 0.001) {\n \n right = uniforms.billboardCameraRight;\n up = vec3f(0.0, 0.0, 1.0); \n } else {\n right = right / rightLen;\n \n up = cross(forward, right);\n }\n } else {\n right = uniforms.billboardCameraRight;\n up = vec3f(0.0, 1.0, 0.0);\n }\n\n let offset = right * input.position.x * scaleX + up * input.position.y * scaleY;\n billboardWorldPos = entityPos + offset;\n useBillboardWorldPos = true;\n localNormal = vec3f(0.0, 1.0, 0.0); \n\n } else if (uniforms.billboardMode < 2.5) {\n \n \n let toCamera = uniforms.cameraPosition - entityPos;\n let toCameraDist = length(toCamera);\n\n var right: vec3f;\n var billUp: vec3f;\n\n if (toCameraDist > 0.001) {\n let forward = toCamera / toCameraDist; \n \n right = cross(vec3f(0.0, 1.0, 0.0), forward);\n let rightLen = length(right);\n if (rightLen < 0.001) {\n \n right = uniforms.billboardCameraRight;\n billUp = uniforms.billboardCameraUp;\n } else {\n right = right / rightLen;\n \n billUp = cross(forward, right);\n }\n } else {\n \n right = uniforms.billboardCameraRight;\n billUp = uniforms.billboardCameraUp;\n }\n\n let offset = right * input.position.x * scaleX + billUp * input.position.y * scaleY;\n billboardWorldPos = entityPos + offset;\n useBillboardWorldPos = true;\n localNormal = vec3f(0.0, 1.0, 0.0); \n\n } else {\n \n \n \n \n localNormal = vec3f(0.0, 1.0, 0.0); \n }\n }\n\n \n if (uniforms.skinEnabled > 0.5) {\n \n let weightSum = input.weights.x + input.weights.y + input.weights.z + input.weights.w;\n if (weightSum > 0.001) {\n localPos = applySkinning(input.position, input.joints, input.weights);\n prevLocalPos = applySkinningPrev(input.position, input.joints, input.weights);\n localNormal = applySkinningNormal(input.normal, input.joints, input.weights);\n }\n }\n\n \n \n \n var worldPos: vec3f;\n if (useBillboardWorldPos) {\n worldPos = billboardWorldPos;\n } else {\n worldPos = (instanceModelMatrix * vec4f(localPos, 1.0)).xyz;\n }\n var viewPos = uniforms.viewMatrix * vec4f(worldPos, 1.0);\n\n \n let allowRounding = posRadius.w >= 0.0;\n\n \n \n if (allowRounding && uniforms.positionRounding > 0.0) {\n let snap = uniforms.positionRounding;\n viewPos = vec4f(\n floor(viewPos.x / snap) * snap,\n floor(viewPos.y / snap) * snap,\n viewPos.z,\n viewPos.w\n );\n }\n\n var clipPos = uniforms.projectionMatrix * viewPos;\n\n \n \n \n var prevWorldPos: vec3f;\n if (useBillboardWorldPos) {\n prevWorldPos = billboardWorldPos; \n } else {\n prevWorldPos = (instanceModelMatrix * vec4f(prevLocalPos, 1.0)).xyz;\n }\n let prevClipPos = uniforms.prevViewProjMatrix * vec4f(prevWorldPos, 1.0);\n\n \n \n let viewDist = -viewPos.z; \n let jitterFade = saturate(1.0 - viewDist / uniforms.jitterFadeDistance);\n\n \n \n let jitterClip = uniforms.jitterOffset * 2.0 / uniforms.screenSize * jitterFade;\n clipPos.x += jitterClip.x * clipPos.w;\n clipPos.y += jitterClip.y * clipPos.w;\n\n \n \n if (allowRounding && uniforms.pixelRounding > 0.5) {\n let gridSize = uniforms.pixelRounding; \n let ndc = clipPos.xy / clipPos.w;\n\n \n let pixelCoords = (ndc + 1.0) * 0.5 * uniforms.screenSize;\n\n \n var snappedPixel = floor(pixelCoords / gridSize) * gridSize;\n\n \n if (uniforms.pixelExpansion > 0.0) {\n let screenCenter = uniforms.screenSize * 0.5;\n let fromCenter = snappedPixel - screenCenter;\n snappedPixel += sign(fromCenter) * uniforms.pixelExpansion;\n }\n\n \n let snappedNDC = (snappedPixel / uniforms.screenSize) * 2.0 - 1.0;\n\n \n clipPos.x = snappedNDC.x * clipPos.w;\n clipPos.y = snappedNDC.y * clipPos.w;\n }\n\n output.position = clipPos;\n output.worldPos = worldPos;\n \n output.uv = input.uv * uvTransform.zw + uvTransform.xy;\n output.viewZ = viewDist;\n output.currClipPos = clipPos;\n output.prevClipPos = prevClipPos;\n output.instanceColor = instanceColor;\n\n \n \n \n \n if (uniforms.billboardMode > 0.5) {\n let entityPos = instanceModelMatrix[3].xyz;\n output.anchorY = entityPos.y;\n } else {\n output.anchorY = worldPos.y; \n }\n\n \n \n if (uniforms.billboardMode > 0.5) {\n output.normal = localNormal; \n } else {\n let normalMatrix = mat3x3f(\n instanceModelMatrix[0].xyz,\n instanceModelMatrix[1].xyz,\n instanceModelMatrix[2].xyz\n );\n output.normal = normalMatrix * localNormal;\n }\n return output;\n}\n\nstruct GBufferOutput {\n @location(0) albedo: vec4f,\n @location(1) normal: vec4f,\n @location(2) arm: vec4f, \n @location(3) emission: vec4f,\n @location(4) velocity: vec2f, \n @builtin(frag_depth) depth: f32, \n}\n\nfn interleavedGradientNoise(screenPos: vec2f) -> f32 {\n let magic = vec3f(0.06711056, 0.00583715, 52.9829189);\n return fract(magic.z * fract(dot(screenPos, magic.xy)));\n}\n\nfn screenHash(screenPos: vec2f) -> f32 {\n let p = fract(screenPos * vec2f(0.1031, 0.1030));\n let p3 = p.xyx * (p.yxy + 33.33);\n return fract((p3.x + p3.y) * p3.z);\n}\n\n@fragment\nfn fragmentMain(input: VertexOutput) -> GBufferOutput {\n \n \n \n \n \n if (uniforms.clipPlaneEnabled > 0.5 && uniforms.billboardMode < 0.5) {\n let clipDist = (input.worldPos.y - uniforms.clipPlaneY) * uniforms.clipPlaneDirection;\n if (clipDist < 0.0) {\n discard;\n }\n }\n\n \n \n if (uniforms.distanceFadeEnd > 0.0) {\n let distToCamera = length(input.worldPos - uniforms.cameraPosition);\n if (distToCamera >= uniforms.distanceFadeEnd) {\n discard; \n }\n if (distToCamera > uniforms.distanceFadeStart) {\n \n let fadeRange = uniforms.distanceFadeEnd - uniforms.distanceFadeStart;\n let fadeFactor = 1.0 - (distToCamera - uniforms.distanceFadeStart) / fadeRange;\n \n let noise = sampleNoise(input.position.xy);\n if (fadeFactor < noise) {\n discard; \n }\n }\n }\n\n var output: GBufferOutput;\n\n \n let mipBias = uniforms.mipBias; \n output.albedo = textureSampleBias(albedoTexture, albedoSampler, input.uv, mipBias);\n\n \n output.albedo = output.albedo * input.instanceColor;\n\n \n \n if (uniforms.luminanceToAlpha > 0.5) {\n let luminance = dot(output.albedo.rgb, vec3f(0.299, 0.587, 0.114));\n if (luminance < 0.004) {\n discard; \n }\n output.albedo.a = 1.0; \n }\n\n \n \n if (uniforms.alphaHashEnabled > 0.5 && uniforms.luminanceToAlpha < 0.5) {\n let alpha = output.albedo.a * uniforms.alphaHashScale;\n let noise = sampleNoise(input.position.xy);\n\n \n if (alpha < 0.5) {\n discard;\n }\n \n \n let remappedAlpha = (alpha - 0.5) * 2.0;\n if (remappedAlpha < noise) {\n discard;\n }\n }\n\n \n\n \n let nsample = textureSampleBias(normalTexture, normalSampler, input.uv, mipBias).rgb;\n let tangentNormal = normalize(nsample * 2.0 - 1.0);\n\n \n let N = normalize(input.normal);\n \n let refVec = select(vec3f(0.0, 1.0, 0.0), vec3f(1.0, 0.0, 0.0), abs(N.y) > 0.9);\n let T = normalize(cross(N, refVec));\n let B = cross(N, T);\n let TBN = mat3x3f(T, B, N);\n\n \n let normal = normalize(TBN * tangentNormal);\n \n output.normal = vec4f(normal, input.worldPos.y);\n\n \n var ambient = textureSampleBias(ambientTexture, ambientSampler, input.uv, mipBias).rgb;\n if (ambient.r < 0.04) {\n ambient.r = 1.0;\n }\n let rm = textureSampleBias(rmTexture, rmSampler, input.uv, mipBias).rgb;\n output.arm = vec4f(ambient.r, rm.g, rm.b, uniforms.specularBoost);\n\n \n output.emission = textureSampleBias(emissionTexture, emissionSampler, input.uv, mipBias) * uniforms.emissionFactor;\n\n \n \n let currNDC = input.currClipPos.xy / input.currClipPos.w;\n let prevNDC = input.prevClipPos.xy / input.prevClipPos.w;\n\n \n \n \n let velocityNDC = currNDC - prevNDC;\n output.velocity = velocityNDC * uniforms.screenSize * 0.5;\n\n \n let near = uniforms.near;\n let far = uniforms.far;\n let z = input.viewZ;\n output.depth = (z - near) / (far - near);\n\n return output;\n}";
6401
+ var geometry_default = "struct VertexInput {\n @location(0) position: vec3f,\n @location(1) uv: vec2f,\n @location(2) normal: vec3f,\n @location(3) color: vec4f,\n @location(4) weights: vec4f,\n @location(5) joints: vec4u,\n}\n\nstruct VertexOutput {\n @invariant @builtin(position) position: vec4f, \n @location(0) worldPos: vec3f,\n @location(1) uv: vec2f,\n @location(2) normal: vec3f,\n @location(3) viewZ: f32, \n @location(4) currClipPos: vec4f, \n @location(5) prevClipPos: vec4f, \n @location(6) instanceColor: vec4f, \n @location(7) anchorY: f32, \n}\n\nstruct Uniforms {\n viewMatrix: mat4x4f,\n projectionMatrix: mat4x4f,\n prevViewProjMatrix: mat4x4f, \n mipBias: f32,\n skinEnabled: f32, \n numJoints: f32, \n near: f32, \n far: f32, \n jitterFadeDistance: f32, \n jitterOffset: vec2f, \n screenSize: vec2f, \n emissionFactor: vec4f,\n clipPlaneY: f32, \n clipPlaneEnabled: f32, \n clipPlaneDirection: f32, \n pixelRounding: f32, \n pixelExpansion: f32, \n positionRounding: f32, \n alphaHashEnabled: f32, \n alphaHashScale: f32, \n luminanceToAlpha: f32, \n noiseSize: f32, \n noiseOffsetX: f32, \n noiseOffsetY: f32, \n cameraPosition: vec3f, \n distanceFadeStart: f32, \n distanceFadeEnd: f32, \n \n billboardMode: f32, \n billboardCameraRight: vec3f, \n _pad1: f32,\n billboardCameraUp: vec3f, \n _pad2: f32,\n billboardCameraForward: vec3f, \n specularBoost: f32, \n}\n\nstruct InstanceData {\n modelMatrix: mat4x4f,\n posRadius: vec4f,\n}\n\n@group(0) @binding(0) var<uniform> uniforms: Uniforms;\n@group(0) @binding(1) var albedoTexture: texture_2d<f32>;\n@group(0) @binding(2) var albedoSampler: sampler;\n@group(0) @binding(3) var normalTexture: texture_2d<f32>;\n@group(0) @binding(4) var normalSampler: sampler;\n@group(0) @binding(5) var ambientTexture: texture_2d<f32>;\n@group(0) @binding(6) var ambientSampler: sampler;\n@group(0) @binding(7) var rmTexture: texture_2d<f32>;\n@group(0) @binding(8) var rmSampler: sampler;\n@group(0) @binding(9) var emissionTexture: texture_2d<f32>;\n@group(0) @binding(10) var emissionSampler: sampler;\n@group(0) @binding(11) var jointTexture: texture_2d<f32>;\n@group(0) @binding(12) var jointSampler: sampler;\n@group(0) @binding(13) var prevJointTexture: texture_2d<f32>; \n@group(0) @binding(14) var noiseTexture: texture_2d<f32>; \n\nfn sampleNoise(screenPos: vec2f) -> f32 {\n let noiseSize = i32(uniforms.noiseSize);\n let noiseOffsetX = i32(uniforms.noiseOffsetX * f32(noiseSize));\n let noiseOffsetY = i32(uniforms.noiseOffsetY * f32(noiseSize));\n\n let texCoord = vec2i(\n (i32(screenPos.x) + noiseOffsetX) % noiseSize,\n (i32(screenPos.y) + noiseOffsetY) % noiseSize\n );\n return textureLoad(noiseTexture, texCoord, 0).r;\n}\n\nfn getJointMatrix(jointIndex: u32) -> mat4x4f {\n let row = i32(jointIndex);\n let col0 = textureLoad(jointTexture, vec2i(0, row), 0);\n let col1 = textureLoad(jointTexture, vec2i(1, row), 0);\n let col2 = textureLoad(jointTexture, vec2i(2, row), 0);\n let col3 = textureLoad(jointTexture, vec2i(3, row), 0);\n return mat4x4f(col0, col1, col2, col3);\n}\n\nfn getPrevJointMatrix(jointIndex: u32) -> mat4x4f {\n let row = i32(jointIndex);\n let col0 = textureLoad(prevJointTexture, vec2i(0, row), 0);\n let col1 = textureLoad(prevJointTexture, vec2i(1, row), 0);\n let col2 = textureLoad(prevJointTexture, vec2i(2, row), 0);\n let col3 = textureLoad(prevJointTexture, vec2i(3, row), 0);\n return mat4x4f(col0, col1, col2, col3);\n}\n\nfn applySkinning(position: vec3f, joints: vec4u, weights: vec4f) -> vec3f {\n var skinnedPos = vec3f(0.0);\n\n let m0 = getJointMatrix(joints.x);\n let m1 = getJointMatrix(joints.y);\n let m2 = getJointMatrix(joints.z);\n let m3 = getJointMatrix(joints.w);\n\n skinnedPos += (m0 * vec4f(position, 1.0)).xyz * weights.x;\n skinnedPos += (m1 * vec4f(position, 1.0)).xyz * weights.y;\n skinnedPos += (m2 * vec4f(position, 1.0)).xyz * weights.z;\n skinnedPos += (m3 * vec4f(position, 1.0)).xyz * weights.w;\n\n return skinnedPos;\n}\n\nfn applySkinningPrev(position: vec3f, joints: vec4u, weights: vec4f) -> vec3f {\n var skinnedPos = vec3f(0.0);\n\n let m0 = getPrevJointMatrix(joints.x);\n let m1 = getPrevJointMatrix(joints.y);\n let m2 = getPrevJointMatrix(joints.z);\n let m3 = getPrevJointMatrix(joints.w);\n\n skinnedPos += (m0 * vec4f(position, 1.0)).xyz * weights.x;\n skinnedPos += (m1 * vec4f(position, 1.0)).xyz * weights.y;\n skinnedPos += (m2 * vec4f(position, 1.0)).xyz * weights.z;\n skinnedPos += (m3 * vec4f(position, 1.0)).xyz * weights.w;\n\n return skinnedPos;\n}\n\nfn applySkinningNormal(normal: vec3f, joints: vec4u, weights: vec4f) -> vec3f {\n var skinnedNormal = vec3f(0.0);\n\n let m0 = getJointMatrix(joints.x);\n let m1 = getJointMatrix(joints.y);\n let m2 = getJointMatrix(joints.z);\n let m3 = getJointMatrix(joints.w);\n\n \n let r0 = mat3x3f(m0[0].xyz, m0[1].xyz, m0[2].xyz);\n let r1 = mat3x3f(m1[0].xyz, m1[1].xyz, m1[2].xyz);\n let r2 = mat3x3f(m2[0].xyz, m2[1].xyz, m2[2].xyz);\n let r3 = mat3x3f(m3[0].xyz, m3[1].xyz, m3[2].xyz);\n\n skinnedNormal += (r0 * normal) * weights.x;\n skinnedNormal += (r1 * normal) * weights.y;\n skinnedNormal += (r2 * normal) * weights.z;\n skinnedNormal += (r3 * normal) * weights.w;\n\n return normalize(skinnedNormal);\n}\n\n@vertex\nfn vertexMain(\n input: VertexInput,\n @builtin(instance_index) instanceIdx : u32,\n @location(6) instanceModelMatrix0: vec4f,\n @location(7) instanceModelMatrix1: vec4f,\n @location(8) instanceModelMatrix2: vec4f,\n @location(9) instanceModelMatrix3: vec4f,\n @location(10) posRadius: vec4f,\n @location(11) uvTransform: vec4f, \n @location(12) instanceColor: vec4f, \n) -> VertexOutput {\n var output: VertexOutput;\n\n \n let instanceModelMatrix = mat4x4f(\n instanceModelMatrix0,\n instanceModelMatrix1,\n instanceModelMatrix2,\n instanceModelMatrix3\n );\n\n var localPos = input.position;\n var prevLocalPos = input.position; \n var localNormal = input.normal;\n var billboardWorldPos: vec3f;\n var useBillboardWorldPos = false;\n\n \n if (uniforms.billboardMode > 0.5) {\n \n let entityPos = instanceModelMatrix[3].xyz;\n\n \n let scaleX = length(instanceModelMatrix[0].xyz);\n let scaleY = length(instanceModelMatrix[1].xyz);\n\n if (uniforms.billboardMode < 1.5) {\n \n \n let toCamera = uniforms.cameraPosition - entityPos;\n let toCameraDist = length(toCamera);\n\n var right: vec3f;\n var up: vec3f;\n\n if (toCameraDist > 0.001) {\n let forward = toCamera / toCameraDist;\n \n right = cross(vec3f(0.0, 1.0, 0.0), forward);\n let rightLen = length(right);\n if (rightLen < 0.001) {\n \n right = uniforms.billboardCameraRight;\n up = vec3f(0.0, 0.0, 1.0); \n } else {\n right = right / rightLen;\n \n up = cross(forward, right);\n }\n } else {\n right = uniforms.billboardCameraRight;\n up = vec3f(0.0, 1.0, 0.0);\n }\n\n let offset = right * input.position.x * scaleX + up * input.position.y * scaleY;\n billboardWorldPos = entityPos + offset;\n useBillboardWorldPos = true;\n localNormal = vec3f(0.0, 1.0, 0.0); \n\n } else if (uniforms.billboardMode < 2.5) {\n \n \n let toCamera = uniforms.cameraPosition - entityPos;\n let toCameraDist = length(toCamera);\n\n var right: vec3f;\n var billUp: vec3f;\n\n if (toCameraDist > 0.001) {\n let forward = toCamera / toCameraDist; \n \n right = cross(vec3f(0.0, 1.0, 0.0), forward);\n let rightLen = length(right);\n if (rightLen < 0.001) {\n \n right = uniforms.billboardCameraRight;\n billUp = uniforms.billboardCameraUp;\n } else {\n right = right / rightLen;\n \n billUp = cross(forward, right);\n }\n } else {\n \n right = uniforms.billboardCameraRight;\n billUp = uniforms.billboardCameraUp;\n }\n\n let offset = right * input.position.x * scaleX + billUp * input.position.y * scaleY;\n billboardWorldPos = entityPos + offset;\n useBillboardWorldPos = true;\n localNormal = vec3f(0.0, 1.0, 0.0); \n\n } else {\n \n \n \n \n localNormal = vec3f(0.0, 1.0, 0.0); \n }\n }\n\n \n if (uniforms.skinEnabled > 0.5) {\n \n let weightSum = input.weights.x + input.weights.y + input.weights.z + input.weights.w;\n if (weightSum > 0.001) {\n localPos = applySkinning(input.position, input.joints, input.weights);\n prevLocalPos = applySkinningPrev(input.position, input.joints, input.weights);\n localNormal = applySkinningNormal(input.normal, input.joints, input.weights);\n }\n }\n\n \n \n \n var worldPos: vec3f;\n if (useBillboardWorldPos) {\n worldPos = billboardWorldPos;\n } else {\n worldPos = (instanceModelMatrix * vec4f(localPos, 1.0)).xyz;\n }\n var viewPos = uniforms.viewMatrix * vec4f(worldPos, 1.0);\n\n \n let allowRounding = posRadius.w >= 0.0;\n\n \n \n if (allowRounding && uniforms.positionRounding > 0.0) {\n let snap = uniforms.positionRounding;\n viewPos = vec4f(\n floor(viewPos.x / snap) * snap,\n floor(viewPos.y / snap) * snap,\n viewPos.z,\n viewPos.w\n );\n }\n\n var clipPos = uniforms.projectionMatrix * viewPos;\n\n \n \n \n var prevWorldPos: vec3f;\n if (useBillboardWorldPos) {\n prevWorldPos = billboardWorldPos; \n } else {\n prevWorldPos = (instanceModelMatrix * vec4f(prevLocalPos, 1.0)).xyz;\n }\n let prevClipPos = uniforms.prevViewProjMatrix * vec4f(prevWorldPos, 1.0);\n\n \n \n let viewDist = -viewPos.z; \n let jitterFade = saturate(1.0 - viewDist / uniforms.jitterFadeDistance);\n\n \n \n let jitterClip = uniforms.jitterOffset * 2.0 / uniforms.screenSize * jitterFade;\n clipPos.x += jitterClip.x * clipPos.w;\n clipPos.y += jitterClip.y * clipPos.w;\n\n \n \n if (allowRounding && uniforms.pixelRounding > 0.5) {\n let gridSize = uniforms.pixelRounding; \n let ndc = clipPos.xy / clipPos.w;\n\n \n let pixelCoords = (ndc + 1.0) * 0.5 * uniforms.screenSize;\n\n \n var snappedPixel = floor(pixelCoords / gridSize) * gridSize;\n\n \n if (uniforms.pixelExpansion > 0.0) {\n let screenCenter = uniforms.screenSize * 0.5;\n let fromCenter = snappedPixel - screenCenter;\n snappedPixel += sign(fromCenter) * uniforms.pixelExpansion;\n }\n\n \n let snappedNDC = (snappedPixel / uniforms.screenSize) * 2.0 - 1.0;\n\n \n clipPos.x = snappedNDC.x * clipPos.w;\n clipPos.y = snappedNDC.y * clipPos.w;\n }\n\n output.position = clipPos;\n output.worldPos = worldPos;\n \n output.uv = input.uv * uvTransform.zw + uvTransform.xy;\n output.viewZ = viewDist;\n output.currClipPos = clipPos;\n output.prevClipPos = prevClipPos;\n output.instanceColor = instanceColor;\n\n \n \n \n \n if (uniforms.billboardMode > 0.5) {\n let entityPos = instanceModelMatrix[3].xyz;\n output.anchorY = entityPos.y;\n } else {\n output.anchorY = worldPos.y; \n }\n\n \n \n if (uniforms.billboardMode > 0.5) {\n output.normal = localNormal; \n } else {\n let normalMatrix = mat3x3f(\n instanceModelMatrix[0].xyz,\n instanceModelMatrix[1].xyz,\n instanceModelMatrix[2].xyz\n );\n output.normal = normalMatrix * localNormal;\n }\n return output;\n}\n\nstruct GBufferOutput {\n @location(0) albedo: vec4f,\n @location(1) normal: vec4f,\n @location(2) arm: vec4f, \n @location(3) emission: vec4f,\n @location(4) velocity: vec2f, \n @builtin(frag_depth) depth: f32, \n}\n\nfn interleavedGradientNoise(screenPos: vec2f) -> f32 {\n let magic = vec3f(0.06711056, 0.00583715, 52.9829189);\n return fract(magic.z * fract(dot(screenPos, magic.xy)));\n}\n\nfn screenHash(screenPos: vec2f) -> f32 {\n let p = fract(screenPos * vec2f(0.1031, 0.1030));\n let p3 = p.xyx * (p.yxy + 33.33);\n return fract((p3.x + p3.y) * p3.z);\n}\n\n@fragment\nfn fragmentMain(input: VertexOutput, @builtin(front_facing) frontFacing: bool) -> GBufferOutput {\n \n \n \n \n \n if (uniforms.clipPlaneEnabled > 0.5 && uniforms.billboardMode < 0.5) {\n let clipDist = (input.worldPos.y - uniforms.clipPlaneY) * uniforms.clipPlaneDirection;\n if (clipDist < 0.0) {\n discard;\n }\n }\n\n \n \n if (uniforms.distanceFadeEnd > 0.0) {\n let distToCamera = length(input.worldPos - uniforms.cameraPosition);\n if (distToCamera >= uniforms.distanceFadeEnd) {\n discard; \n }\n if (distToCamera > uniforms.distanceFadeStart) {\n \n let fadeRange = uniforms.distanceFadeEnd - uniforms.distanceFadeStart;\n let fadeFactor = 1.0 - (distToCamera - uniforms.distanceFadeStart) / fadeRange;\n \n let noise = sampleNoise(input.position.xy);\n if (fadeFactor < noise) {\n discard; \n }\n }\n }\n\n var output: GBufferOutput;\n\n \n let mipBias = uniforms.mipBias; \n output.albedo = textureSampleBias(albedoTexture, albedoSampler, input.uv, mipBias);\n\n \n output.albedo = output.albedo * input.instanceColor;\n\n \n \n if (uniforms.luminanceToAlpha > 0.5) {\n let luminance = dot(output.albedo.rgb, vec3f(0.299, 0.587, 0.114));\n if (luminance < 0.004) {\n discard; \n }\n output.albedo.a = 1.0; \n }\n\n \n \n if (uniforms.alphaHashEnabled > 0.5 && uniforms.luminanceToAlpha < 0.5) {\n let alpha = output.albedo.a * uniforms.alphaHashScale;\n let noise = sampleNoise(input.position.xy);\n\n \n if (alpha < 0.5) {\n discard;\n }\n \n \n let remappedAlpha = (alpha - 0.5) * 2.0;\n if (remappedAlpha < noise) {\n discard;\n }\n }\n\n \n\n \n let nsample = textureSampleBias(normalTexture, normalSampler, input.uv, mipBias).rgb;\n let tangentNormal = normalize(nsample * 2.0 - 1.0);\n\n \n \n let dPdx = dpdx(input.worldPos);\n let dPdy = dpdy(input.worldPos);\n let dUVdx = dpdx(input.uv);\n let dUVdy = dpdy(input.uv);\n\n let N = normalize(input.normal);\n\n \n let dp2perp = cross(dPdy, N);\n let dp1perp = cross(N, dPdx);\n\n \n \n var T = -(dp2perp * dUVdx.x + dp1perp * dUVdy.x);\n var B = dp2perp * dUVdx.y + dp1perp * dUVdy.y;\n\n \n let invmax = inverseSqrt(max(dot(T, T), dot(B, B)));\n T = T * invmax;\n B = B * invmax;\n\n \n if (length(T) < 0.001 || length(B) < 0.001) {\n let refVec = select(vec3f(0.0, 1.0, 0.0), vec3f(1.0, 0.0, 0.0), abs(N.y) > 0.9);\n T = normalize(cross(N, refVec));\n B = cross(N, T);\n }\n\n \n if (!frontFacing) {\n T = -T;\n B = -B;\n }\n\n let TBN = mat3x3f(T, B, N);\n\n \n let normal = normalize(TBN * tangentNormal);\n \n output.normal = vec4f(normal, input.worldPos.y);\n\n \n var ambient = textureSampleBias(ambientTexture, ambientSampler, input.uv, mipBias).rgb;\n if (ambient.r < 0.04) {\n ambient.r = 1.0;\n }\n let rm = textureSampleBias(rmTexture, rmSampler, input.uv, mipBias).rgb;\n output.arm = vec4f(ambient.r, rm.g, rm.b, uniforms.specularBoost);\n\n \n output.emission = textureSampleBias(emissionTexture, emissionSampler, input.uv, mipBias) * uniforms.emissionFactor;\n\n \n \n let currNDC = input.currClipPos.xy / input.currClipPos.w;\n let prevNDC = input.prevClipPos.xy / input.prevClipPos.w;\n\n \n \n \n let velocityNDC = currNDC - prevNDC;\n output.velocity = velocityNDC * uniforms.screenSize * 0.5;\n\n \n let near = uniforms.near;\n let far = uniforms.far;\n let z = input.viewZ;\n output.depth = (z - near) / (far - near);\n\n return output;\n}";
6187
6402
  class GBuffer {
6188
6403
  constructor() {
6189
6404
  this.isGBuffer = true;
@@ -6404,7 +6619,8 @@ class GBufferPass extends BasePass {
6404
6619
  const isSkinned = mesh.hasSkin && mesh.skin;
6405
6620
  const meshId = mesh.uid || mesh.geometry?.uid || "default";
6406
6621
  const forceEmissive = mesh.material?.forceEmissive ? "_emissive" : "";
6407
- return `${mesh.material.uid}_${meshId}${isSkinned ? "_skinned" : ""}${forceEmissive}`;
6622
+ const doubleSided = mesh.material?.doubleSided ? "_dbl" : "";
6623
+ return `${mesh.material.uid}_${meshId}${isSkinned ? "_skinned" : ""}${forceEmissive}${doubleSided}`;
6408
6624
  }
6409
6625
  /**
6410
6626
  * Check if pipeline is ready for a mesh (non-blocking)
@@ -6444,7 +6660,8 @@ class GBufferPass extends BasePass {
6444
6660
  textures: mesh.material.textures,
6445
6661
  renderTarget: this.gbuffer,
6446
6662
  skin: isSkinned ? mesh.skin : null,
6447
- noiseTexture: this.noiseTexture
6663
+ noiseTexture: this.noiseTexture,
6664
+ doubleSided: mesh.material?.doubleSided ?? false
6448
6665
  }).then((pipeline) => {
6449
6666
  this.pendingPipelines.delete(key);
6450
6667
  pipeline._warmupFrames = 2;
@@ -6479,7 +6696,8 @@ class GBufferPass extends BasePass {
6479
6696
  textures: mesh.material.textures,
6480
6697
  renderTarget: this.gbuffer,
6481
6698
  skin: isSkinned ? mesh.skin : null,
6482
- noiseTexture: this.noiseTexture
6699
+ noiseTexture: this.noiseTexture,
6700
+ doubleSided: mesh.material?.doubleSided ?? false
6483
6701
  });
6484
6702
  pipeline._warmupFrames = 2;
6485
6703
  pipelinesMap.set(key, pipeline);
@@ -6502,6 +6720,8 @@ class GBufferPass extends BasePass {
6502
6720
  const prevViewProjMatrix = prevData?.hasValidHistory ? prevData.viewProj : camera.viewProj;
6503
6721
  const emissionFactor = this.settings?.environment?.emissionFactor ?? [1, 1, 1, 4];
6504
6722
  const mipBias = this.settings?.rendering?.mipBias ?? options.mipBias ?? 0;
6723
+ const animationSpeed = this.settings?.animation?.speed ?? 1;
6724
+ const globalAnimTime = performance.now() / 1e3 * animationSpeed;
6505
6725
  stats.drawCalls = 0;
6506
6726
  stats.triangles = 0;
6507
6727
  camera.aspect = canvas.width / canvas.height;
@@ -6510,12 +6730,18 @@ class GBufferPass extends BasePass {
6510
6730
  this._extractCameraVectors(camera.view);
6511
6731
  let commandEncoder = null;
6512
6732
  let passEncoder = null;
6733
+ const updatedSkins = /* @__PURE__ */ new Set();
6513
6734
  if (batches && batches.size > 0) {
6514
6735
  for (const [modelId, batch] of batches) {
6515
6736
  const mesh = batch.mesh;
6516
6737
  if (!mesh) continue;
6517
- if (batch.hasSkin && batch.skin && !batch.skin.externallyManaged) {
6518
- batch.skin.update(dt);
6738
+ if (batch.hasSkin && batch.skin && !batch.skin.externallyManaged && !updatedSkins.has(batch.skin)) {
6739
+ if (batch.skin._animStartTime === void 0) {
6740
+ batch.skin._animStartTime = globalAnimTime;
6741
+ }
6742
+ const skinAnimTime = globalAnimTime - batch.skin._animStartTime;
6743
+ batch.skin.updateAtTime(skinAnimTime);
6744
+ updatedSkins.add(batch.skin);
6519
6745
  }
6520
6746
  const pipeline = await this._getOrCreatePipeline(mesh);
6521
6747
  if (pipeline._warmupFrames > 0) {
@@ -6634,8 +6860,13 @@ class GBufferPass extends BasePass {
6634
6860
  pipeline._warmupFrames--;
6635
6861
  }
6636
6862
  this.legacyCullingStats.rendered++;
6637
- if (mesh.skin && mesh.hasSkin && !mesh.skin.externallyManaged) {
6638
- mesh.skin.update(dt);
6863
+ if (mesh.skin && mesh.hasSkin && !mesh.skin.externallyManaged && !updatedSkins.has(mesh.skin)) {
6864
+ if (mesh.skin._animStartTime === void 0) {
6865
+ mesh.skin._animStartTime = globalAnimTime;
6866
+ }
6867
+ const skinAnimTime = globalAnimTime - mesh.skin._animStartTime;
6868
+ mesh.skin.updateAtTime(skinAnimTime);
6869
+ updatedSkins.add(mesh.skin);
6639
6870
  }
6640
6871
  if (pipeline.geometry !== mesh.geometry) {
6641
6872
  pipeline.geometry = mesh.geometry;
@@ -7258,9 +7489,21 @@ class LightingPass extends BasePass {
7258
7489
  getOutputTexture() {
7259
7490
  return this.outputTexture;
7260
7491
  }
7492
+ /**
7493
+ * Get the light buffer for volumetric fog
7494
+ */
7495
+ getLightBuffer() {
7496
+ return this.lightBuffer;
7497
+ }
7498
+ /**
7499
+ * Get the current light count
7500
+ */
7501
+ getLightCount() {
7502
+ return this.lights?.length ?? 0;
7503
+ }
7261
7504
  }
7262
7505
  var particle_simulate_default = "const WORKGROUP_SIZE: u32 = 64u;\nconst MAX_EMITTERS: u32 = 16u;\nconst CASCADE_COUNT = 3;\nconst MAX_LIGHTS = 64u;\nconst MAX_SPOT_SHADOWS = 8;\nconst LIGHTING_FADE_TIME: f32 = 0.3; \n\nconst SPOT_ATLAS_WIDTH: f32 = 2048.0;\nconst SPOT_ATLAS_HEIGHT: f32 = 2048.0;\nconst SPOT_TILE_SIZE: f32 = 512.0;\nconst SPOT_TILES_PER_ROW: i32 = 4;\n\nstruct Particle {\n position: vec3f, \n lifetime: f32, \n velocity: vec3f, \n maxLifetime: f32, \n color: vec4f, \n size: vec2f, \n rotation: f32, \n flags: u32, \n lighting: vec3f, \n lightingPad: f32, \n}\n\nstruct Light {\n enabled: u32,\n position: vec3f,\n color: vec4f,\n direction: vec3f,\n geom: vec4f, \n shadowIndex: i32,\n}\n\nstruct CascadeMatrices {\n matrices: array<mat4x4<f32>, CASCADE_COUNT>,\n}\n\nstruct SpotShadowMatrices {\n matrices: array<mat4x4<f32>, MAX_SPOT_SHADOWS>,\n}\n\nstruct SpawnRequest {\n position: vec3f,\n lifetime: f32,\n velocity: vec3f,\n maxLifetime: f32,\n color: vec4f,\n startSize: f32,\n endSize: f32,\n rotation: f32, \n flags: u32, \n}\n\nstruct EmitterSettings {\n gravity: vec3f,\n drag: f32,\n turbulence: f32,\n fadeIn: f32,\n fadeOut: f32,\n rotationSpeed: f32,\n startSize: f32,\n endSize: f32,\n baseAlpha: f32,\n padding: f32,\n}\n\nstruct SimulationUniforms {\n dt: f32,\n time: f32,\n maxParticles: u32,\n emitterCount: u32,\n \n cameraPosition: vec3f,\n shadowBias: f32,\n lightDir: vec3f,\n shadowStrength: f32,\n lightColor: vec4f,\n ambientColor: vec4f,\n cascadeSizes: vec4f,\n lightCount: u32,\n pad1: u32,\n pad2: u32,\n pad3: u32,\n}\n\nstruct Counters {\n aliveCount: atomic<u32>,\n nextFreeIndex: atomic<u32>,\n spawnCount: u32,\n frameCount: u32,\n}\n\n@group(0) @binding(0) var<uniform> uniforms: SimulationUniforms;\n@group(0) @binding(1) var<storage, read_write> particles: array<Particle>;\n@group(0) @binding(2) var<storage, read_write> counters: Counters;\n@group(0) @binding(3) var<storage, read> spawnRequests: array<SpawnRequest>;\n@group(0) @binding(4) var<storage, read> emitterSettings: array<EmitterSettings>;\n\n@group(0) @binding(5) var<storage, read> emitterRenderSettings: array<EmitterRenderSettings>;\n@group(0) @binding(6) var shadowMapArray: texture_depth_2d_array;\n@group(0) @binding(7) var shadowSampler: sampler_comparison;\n@group(0) @binding(8) var<storage, read> cascadeMatrices: CascadeMatrices;\n@group(0) @binding(9) var<storage, read> lights: array<Light, MAX_LIGHTS>;\n@group(0) @binding(10) var spotShadowAtlas: texture_depth_2d;\n@group(0) @binding(11) var<storage, read> spotMatrices: SpotShadowMatrices;\n\nstruct EmitterRenderSettings {\n lit: f32,\n emissive: f32,\n softness: f32,\n zOffset: f32,\n}\n\nfn hash(p: vec3f) -> f32 {\n var p3 = fract(p * 0.1031);\n p3 += dot(p3, p3.yzx + 33.33);\n return fract((p3.x + p3.y) * p3.z);\n}\n\nfn noise3D(p: vec3f) -> vec3f {\n let i = floor(p);\n let f = fract(p);\n let u = f * f * (3.0 - 2.0 * f);\n\n return vec3f(\n mix(hash(i), hash(i + vec3f(1.0, 0.0, 0.0)), u.x),\n mix(hash(i + vec3f(0.0, 1.0, 0.0)), hash(i + vec3f(1.0, 1.0, 0.0)), u.x),\n mix(hash(i + vec3f(0.0, 0.0, 1.0)), hash(i + vec3f(1.0, 0.0, 1.0)), u.x)\n ) * 2.0 - 1.0;\n}\n\nfn squircleDistanceXZ(offset: vec2f, size: f32) -> f32 {\n let normalized = offset / size;\n let absNorm = abs(normalized);\n return pow(pow(absNorm.x, 4.0) + pow(absNorm.y, 4.0), 0.25);\n}\n\nfn sampleCascadeShadow(worldPos: vec3f, cascadeIndex: i32) -> f32 {\n let bias = uniforms.shadowBias;\n let lightMatrix = cascadeMatrices.matrices[cascadeIndex];\n let lightSpacePos = lightMatrix * vec4f(worldPos, 1.0);\n let projCoords = lightSpacePos.xyz / lightSpacePos.w;\n let shadowUV = vec2f(projCoords.x * 0.5 + 0.5, 0.5 - projCoords.y * 0.5);\n let currentDepth = projCoords.z - bias;\n\n \n if (shadowUV.x < 0.0 || shadowUV.x > 1.0 || shadowUV.y < 0.0 || shadowUV.y > 1.0 ||\n currentDepth < 0.0 || currentDepth > 1.0) {\n return 1.0;\n }\n\n \n return textureSampleCompareLevel(shadowMapArray, shadowSampler, shadowUV, cascadeIndex, currentDepth);\n}\n\nfn calculateParticleShadow(worldPos: vec3f) -> f32 {\n let camXZ = vec2f(uniforms.cameraPosition.x, uniforms.cameraPosition.z);\n let posXZ = vec2f(worldPos.x, worldPos.z);\n let offsetXZ = posXZ - camXZ;\n\n let dist0 = squircleDistanceXZ(offsetXZ, uniforms.cascadeSizes.x);\n let dist1 = squircleDistanceXZ(offsetXZ, uniforms.cascadeSizes.y);\n let dist2 = squircleDistanceXZ(offsetXZ, uniforms.cascadeSizes.z);\n\n var shadow = 1.0;\n if (dist0 < 0.95) {\n shadow = sampleCascadeShadow(worldPos, 0);\n } else if (dist1 < 0.95) {\n shadow = sampleCascadeShadow(worldPos, 1);\n } else if (dist2 < 0.95) {\n shadow = sampleCascadeShadow(worldPos, 2);\n }\n\n return mix(1.0 - uniforms.shadowStrength, 1.0, shadow);\n}\n\nfn calculateSpotShadow(worldPos: vec3f, slotIndex: i32) -> f32 {\n if (slotIndex < 0 || slotIndex >= MAX_SPOT_SHADOWS) {\n return 1.0;\n }\n\n let lightMatrix = spotMatrices.matrices[slotIndex];\n let lightSpacePos = lightMatrix * vec4f(worldPos, 1.0);\n let w = max(abs(lightSpacePos.w), 0.0001) * sign(lightSpacePos.w + 0.0001);\n let projCoords = lightSpacePos.xyz / w;\n\n if (projCoords.z < 0.0 || projCoords.z > 1.0 || abs(projCoords.x) > 1.0 || abs(projCoords.y) > 1.0) {\n return 1.0;\n }\n\n let col = slotIndex % SPOT_TILES_PER_ROW;\n let row = slotIndex / SPOT_TILES_PER_ROW;\n let localUV = vec2f(projCoords.x * 0.5 + 0.5, 0.5 - projCoords.y * 0.5);\n let tileOffset = vec2f(f32(col), f32(row)) * SPOT_TILE_SIZE;\n let atlasUV = (tileOffset + localUV * SPOT_TILE_SIZE) / vec2f(SPOT_ATLAS_WIDTH, SPOT_ATLAS_HEIGHT);\n let currentDepth = clamp(projCoords.z - uniforms.shadowBias * 3.0, 0.001, 0.999);\n\n return textureSampleCompareLevel(spotShadowAtlas, shadowSampler, atlasUV, currentDepth);\n}\n\nfn calculateParticleLighting(worldPos: vec3f, emitterIdx: u32) -> vec3f {\n let renderSettings = emitterRenderSettings[min(emitterIdx, MAX_EMITTERS - 1u)];\n\n \n if (renderSettings.lit < 0.5) {\n return vec3f(renderSettings.emissive);\n }\n\n \n let normal = vec3f(0.0, 1.0, 0.0);\n\n \n var lighting = uniforms.ambientColor.rgb * uniforms.ambientColor.a;\n\n \n let shadow = calculateParticleShadow(worldPos);\n let NdotL = max(dot(normal, uniforms.lightDir), 0.0);\n lighting += uniforms.lightColor.rgb * uniforms.lightColor.a * NdotL * shadow;\n\n \n let lightCount = uniforms.lightCount;\n for (var i = 0u; i < min(lightCount, MAX_LIGHTS); i++) {\n let light = lights[i];\n if (light.enabled == 0u) { continue; }\n\n let lightVec = light.position - worldPos;\n let dist = length(lightVec);\n let lightDir = normalize(lightVec);\n let distanceFade = light.geom.w;\n\n \n let radius = max(light.geom.x, 0.001);\n let attenuation = max(0.0, 1.0 - dist / radius);\n let attenuationSq = attenuation * attenuation * distanceFade;\n if (attenuationSq <= 0.001) { continue; }\n\n \n let innerCone = light.geom.y;\n let outerCone = light.geom.z;\n var spotAttenuation = 1.0;\n let isSpotlight = outerCone > 0.0;\n if (isSpotlight) {\n let spotCos = dot(-lightDir, normalize(light.direction));\n spotAttenuation = smoothstep(outerCone, innerCone, spotCos);\n }\n if (spotAttenuation <= 0.001) { continue; }\n\n \n var lightShadow = 1.0;\n if (isSpotlight && light.shadowIndex >= 0) {\n lightShadow = calculateSpotShadow(worldPos, light.shadowIndex);\n }\n\n \n let pNdotL = max(dot(normal, lightDir), 0.0);\n let pIntensity = light.color.a * attenuationSq * spotAttenuation * lightShadow * pNdotL;\n lighting += pIntensity * light.color.rgb;\n }\n\n return lighting * renderSettings.emissive;\n}\n\n@compute @workgroup_size(WORKGROUP_SIZE)\nfn spawn(@builtin(global_invocation_id) globalId: vec3u) {\n let idx = globalId.x;\n\n \n if (idx >= counters.spawnCount) {\n return;\n }\n\n \n let maxAttempts = 8u; \n var particleIdx = 0u;\n var foundSlot = false;\n\n for (var attempt = 0u; attempt < maxAttempts; attempt++) {\n let rawIdx = atomicAdd(&counters.nextFreeIndex, 1u);\n particleIdx = rawIdx % uniforms.maxParticles;\n\n \n let existing = particles[particleIdx];\n if ((existing.flags & 1u) == 0u || existing.lifetime <= 0.0) {\n foundSlot = true;\n break;\n }\n }\n\n \n if (!foundSlot) {\n return;\n }\n\n \n let req = spawnRequests[idx];\n\n \n var p: Particle;\n p.position = req.position;\n p.lifetime = req.lifetime;\n p.velocity = req.velocity;\n p.maxLifetime = req.maxLifetime;\n p.color = req.color;\n p.size = vec2f(req.startSize, req.startSize);\n p.rotation = req.rotation; \n p.flags = req.flags;\n \n p.lighting = uniforms.ambientColor.rgb * uniforms.ambientColor.a;\n p.lightingPad = 0.0;\n\n \n particles[particleIdx] = p;\n\n \n atomicAdd(&counters.aliveCount, 1u);\n}\n\n@compute @workgroup_size(WORKGROUP_SIZE)\nfn simulate(@builtin(global_invocation_id) globalId: vec3u) {\n let idx = globalId.x;\n\n \n if (idx >= uniforms.maxParticles) {\n return;\n }\n\n var p = particles[idx];\n\n \n if ((p.flags & 1u) == 0u || p.lifetime <= 0.0) {\n return;\n }\n\n \n let emitterIdx = (p.flags >> 8u) & 0xFFu;\n let settings = emitterSettings[min(emitterIdx, MAX_EMITTERS - 1u)];\n\n let dt = uniforms.dt;\n\n \n p.lifetime -= dt;\n\n \n if (p.lifetime <= 0.0) {\n p.flags = 0u; \n p.lifetime = 0.0;\n atomicSub(&counters.aliveCount, 1u);\n particles[idx] = p;\n return;\n }\n\n \n let lifeProgress = 1.0 - (p.lifetime / p.maxLifetime);\n\n \n p.velocity += settings.gravity * dt;\n\n \n let dragFactor = 1.0 - settings.drag * dt;\n p.velocity *= max(dragFactor, 0.0);\n\n \n if (settings.turbulence > 0.0) {\n let noisePos = p.position * 0.5 + vec3f(uniforms.time * 0.1, 0.0, 0.0);\n let turbulenceForce = noise3D(noisePos + vec3f(p.rotation * 10.0)) * settings.turbulence;\n p.velocity += turbulenceForce * dt * 10.0;\n }\n\n \n p.position += p.velocity * dt;\n\n \n let currentSize = mix(settings.startSize, settings.endSize, lifeProgress);\n p.size = vec2f(currentSize, currentSize);\n\n \n let rotationDir = select(-1.0, 1.0, p.rotation >= 0.0);\n p.rotation += rotationDir * settings.rotationSpeed * dt;\n\n \n var alpha = settings.baseAlpha;\n\n \n let fadeInDuration = settings.fadeIn / p.maxLifetime;\n if (lifeProgress < fadeInDuration && fadeInDuration > 0.0) {\n alpha *= lifeProgress / fadeInDuration;\n }\n\n \n let fadeOutStart = 1.0 - (settings.fadeOut / p.maxLifetime);\n if (lifeProgress > fadeOutStart && fadeOutStart < 1.0) {\n let fadeOutProgress = (lifeProgress - fadeOutStart) / (1.0 - fadeOutStart);\n alpha *= 1.0 - fadeOutProgress;\n }\n\n \n p.color.a = alpha;\n\n \n let targetLighting = calculateParticleLighting(p.position, emitterIdx);\n\n \n \n let lerpFactor = clamp(dt / LIGHTING_FADE_TIME, 0.0, 1.0);\n p.lighting = mix(p.lighting, targetLighting, lerpFactor);\n\n \n particles[idx] = p;\n}\n\n@compute @workgroup_size(1)\nfn resetCounters() {\n \n atomicStore(&counters.nextFreeIndex, 0u);\n}";
7263
- var particle_render_default = "const PI = 3.14159265359;\nconst CASCADE_COUNT = 3;\nconst MAX_EMITTERS = 16u;\nconst MAX_LIGHTS = 64u;\nconst MAX_SPOT_SHADOWS = 8;\n\nconst SPOT_ATLAS_WIDTH: f32 = 2048.0;\nconst SPOT_ATLAS_HEIGHT: f32 = 2048.0;\nconst SPOT_TILE_SIZE: f32 = 512.0;\nconst SPOT_TILES_PER_ROW: i32 = 4;\n\nstruct Particle {\n position: vec3f,\n lifetime: f32,\n velocity: vec3f,\n maxLifetime: f32,\n color: vec4f,\n size: vec2f,\n rotation: f32, \n flags: u32,\n lighting: vec3f, \n lightingPad: f32,\n}\n\nstruct Light {\n enabled: u32,\n position: vec3f,\n color: vec4f,\n direction: vec3f,\n geom: vec4f, \n shadowIndex: i32, \n}\n\nstruct EmitterRenderSettings {\n lit: f32, \n emissive: f32, \n softness: f32,\n zOffset: f32,\n}\n\nstruct SpotShadowMatrices {\n matrices: array<mat4x4<f32>, MAX_SPOT_SHADOWS>,\n}\n\nstruct ParticleUniforms {\n viewMatrix: mat4x4f,\n projectionMatrix: mat4x4f,\n cameraPosition: vec3f,\n time: f32,\n cameraRight: vec3f,\n softness: f32,\n cameraUp: vec3f,\n zOffset: f32,\n screenSize: vec2f,\n near: f32,\n far: f32,\n blendMode: f32, \n lit: f32, \n shadowBias: f32,\n shadowStrength: f32,\n \n lightDir: vec3f,\n shadowMapSize: f32,\n lightColor: vec4f,\n ambientColor: vec4f,\n cascadeSizes: vec4f, \n \n envParams: vec4f, \n \n lightParams: vec4u, \n \n fogColor: vec3f,\n fogEnabled: f32,\n fogDistances: vec3f, \n fogBrightResist: f32,\n fogAlphas: vec3f, \n fogPad1: f32,\n fogHeightFade: vec2f, \n fogPad2: vec2f,\n}\n\nstruct CascadeMatrices {\n matrices: array<mat4x4<f32>, CASCADE_COUNT>,\n}\n\nstruct VertexOutput {\n @builtin(position) position: vec4f,\n @location(0) uv: vec2f,\n @location(1) color: vec4f,\n @location(2) viewZ: f32,\n @location(3) linearDepth: f32, \n @location(4) lighting: vec3f, \n @location(5) @interpolate(flat) emitterIdx: u32, \n @location(6) worldPos: vec3f, \n}\n\n@group(0) @binding(0) var<uniform> uniforms: ParticleUniforms;\n@group(0) @binding(1) var<storage, read> particles: array<Particle>;\n@group(0) @binding(2) var particleTexture: texture_2d<f32>;\n@group(0) @binding(3) var particleSampler: sampler;\n@group(0) @binding(4) var depthTexture: texture_depth_2d;\n@group(0) @binding(5) var shadowMapArray: texture_depth_2d_array;\n@group(0) @binding(6) var shadowSampler: sampler_comparison;\n@group(0) @binding(7) var<storage, read> cascadeMatrices: CascadeMatrices;\n@group(0) @binding(8) var<storage, read> emitterSettings: array<EmitterRenderSettings, MAX_EMITTERS>;\n@group(0) @binding(9) var envMap: texture_2d<f32>;\n@group(0) @binding(10) var envSampler: sampler;\n\n@group(0) @binding(11) var<storage, read> lights: array<Light, MAX_LIGHTS>;\n\n@group(0) @binding(12) var spotShadowAtlas: texture_depth_2d;\n@group(0) @binding(13) var spotShadowSampler: sampler_comparison;\n@group(0) @binding(14) var<storage, read> spotMatrices: SpotShadowMatrices;\n\nfn getQuadVertex(vertexId: u32) -> vec2f {\n \n \n \n var corners = array<vec2f, 6>(\n vec2f(-0.5, -0.5), \n vec2f(0.5, -0.5), \n vec2f(-0.5, 0.5), \n vec2f(0.5, -0.5), \n vec2f(0.5, 0.5), \n vec2f(-0.5, 0.5) \n );\n return corners[vertexId];\n}\n\nfn getQuadUV(vertexId: u32) -> vec2f {\n var uvs = array<vec2f, 6>(\n vec2f(0.0, 1.0), \n vec2f(1.0, 1.0), \n vec2f(0.0, 0.0), \n vec2f(1.0, 1.0), \n vec2f(1.0, 0.0), \n vec2f(0.0, 0.0) \n );\n return uvs[vertexId];\n}\n\n@vertex\nfn vertexMain(\n @builtin(vertex_index) vertexIndex: u32,\n @builtin(instance_index) instanceIndex: u32\n) -> VertexOutput {\n var output: VertexOutput;\n\n \n let particle = particles[instanceIndex];\n\n \n let emitterIdx = (particle.flags >> 8u) & 0xFFu;\n output.emitterIdx = emitterIdx;\n\n \n if ((particle.flags & 1u) == 0u || particle.lifetime <= 0.0) {\n \n output.position = vec4f(0.0, 0.0, 0.0, 0.0);\n output.uv = vec2f(0.0, 0.0);\n output.color = vec4f(0.0, 0.0, 0.0, 0.0);\n output.viewZ = 0.0;\n output.linearDepth = 0.0;\n output.lighting = vec3f(0.0);\n output.worldPos = vec3f(0.0);\n return output;\n }\n\n \n \n let particleIsAdditive = (particle.flags & 2u) != 0u;\n let renderingAdditive = uniforms.blendMode > 0.5;\n if (particleIsAdditive != renderingAdditive) {\n \n output.position = vec4f(0.0, 0.0, 0.0, 0.0);\n output.uv = vec2f(0.0, 0.0);\n output.color = vec4f(0.0, 0.0, 0.0, 0.0);\n output.viewZ = 0.0;\n output.linearDepth = 0.0;\n output.lighting = vec3f(0.0);\n output.worldPos = vec3f(0.0);\n return output;\n }\n\n \n let localVertexId = vertexIndex % 6u;\n var quadPos = getQuadVertex(localVertexId);\n output.uv = getQuadUV(localVertexId);\n\n \n let cosR = cos(particle.rotation);\n let sinR = sin(particle.rotation);\n let rotatedPos = vec2f(\n quadPos.x * cosR - quadPos.y * sinR,\n quadPos.x * sinR + quadPos.y * cosR\n );\n\n \n let particleWorldPos = particle.position;\n\n \n let scaledOffset = rotatedPos * particle.size;\n\n \n let right = uniforms.cameraRight;\n let up = uniforms.cameraUp;\n let billboardPos = particleWorldPos + right * scaledOffset.x + up * scaledOffset.y;\n\n \n let toCamera = normalize(uniforms.cameraPosition - particleWorldPos);\n let offsetPos = billboardPos + toCamera * uniforms.zOffset;\n\n \n let viewPos = uniforms.viewMatrix * vec4f(offsetPos, 1.0);\n output.position = uniforms.projectionMatrix * viewPos;\n output.viewZ = -viewPos.z; \n\n \n let z = -viewPos.z; \n output.linearDepth = (z - uniforms.near) / (uniforms.far - uniforms.near);\n\n \n output.lighting = particle.lighting;\n\n \n output.color = particle.color;\n\n \n output.worldPos = particleWorldPos;\n\n return output;\n}\n\nfn SphToUV(n: vec3f) -> vec2f {\n var uv: vec2f;\n uv.x = atan2(-n.x, n.z);\n uv.x = (uv.x + PI / 2.0) / (PI * 2.0) + PI * (28.670 / 360.0);\n uv.y = acos(n.y) / PI;\n return uv;\n}\n\nfn octEncode(n: vec3f) -> vec2f {\n var n2 = n / (abs(n.x) + abs(n.y) + abs(n.z));\n if (n2.y < 0.0) {\n let signX = select(-1.0, 1.0, n2.x >= 0.0);\n let signZ = select(-1.0, 1.0, n2.z >= 0.0);\n n2 = vec3f(\n (1.0 - abs(n2.z)) * signX,\n n2.y,\n (1.0 - abs(n2.x)) * signZ\n );\n }\n return n2.xz * 0.5 + 0.5;\n}\n\nfn getEnvUV(dir: vec3f) -> vec2f {\n if (uniforms.envParams.z > 0.5) {\n return octEncode(dir);\n }\n return SphToUV(dir);\n}\n\nfn getIBLSample(dir: vec3f, lod: f32) -> vec3f {\n let envRGBE = textureSampleLevel(envMap, envSampler, getEnvUV(dir), lod);\n \n let envColor = envRGBE.rgb * pow(2.0, envRGBE.a * 255.0 - 128.0);\n return envColor;\n}\n\nfn squircleDistanceXZ(offset: vec2f, size: f32) -> f32 {\n let normalized = offset / size;\n let absNorm = abs(normalized);\n return pow(pow(absNorm.x, 4.0) + pow(absNorm.y, 4.0), 0.25);\n}\n\nfn sampleCascadeShadow(worldPos: vec3f, normal: vec3f, cascadeIndex: i32) -> f32 {\n let bias = uniforms.shadowBias;\n let shadowMapSize = uniforms.shadowMapSize;\n\n \n let biasedPos = worldPos + normal * bias * 0.5;\n\n \n let lightMatrix = cascadeMatrices.matrices[cascadeIndex];\n\n \n let lightSpacePos = lightMatrix * vec4f(biasedPos, 1.0);\n let projCoords = lightSpacePos.xyz / lightSpacePos.w;\n\n \n let shadowUV = vec2f(projCoords.x * 0.5 + 0.5, 0.5 - projCoords.y * 0.5);\n let currentDepth = projCoords.z - bias;\n\n \n let inBoundsX = shadowUV.x >= 0.0 && shadowUV.x <= 1.0;\n let inBoundsY = shadowUV.y >= 0.0 && shadowUV.y <= 1.0;\n let inBoundsZ = currentDepth >= 0.0 && currentDepth <= 1.0;\n\n if (!inBoundsX || !inBoundsY || !inBoundsZ) {\n return 1.0; \n }\n\n let clampedUV = clamp(shadowUV, vec2f(0.001), vec2f(0.999));\n let clampedDepth = clamp(currentDepth, 0.001, 0.999);\n\n \n let texelSize = 1.0 / shadowMapSize;\n var shadow = 0.0;\n shadow += textureSampleCompareLevel(shadowMapArray, shadowSampler, clampedUV + vec2f(-texelSize, 0.0), cascadeIndex, clampedDepth);\n shadow += textureSampleCompareLevel(shadowMapArray, shadowSampler, clampedUV + vec2f(texelSize, 0.0), cascadeIndex, clampedDepth);\n shadow += textureSampleCompareLevel(shadowMapArray, shadowSampler, clampedUV + vec2f(0.0, -texelSize), cascadeIndex, clampedDepth);\n shadow += textureSampleCompareLevel(shadowMapArray, shadowSampler, clampedUV + vec2f(0.0, texelSize), cascadeIndex, clampedDepth);\n shadow /= 4.0;\n\n return shadow;\n}\n\nfn calculateParticleShadow(worldPos: vec3f, normal: vec3f) -> f32 {\n let shadowStrength = uniforms.shadowStrength;\n\n \n let camXZ = vec2f(uniforms.cameraPosition.x, uniforms.cameraPosition.z);\n let posXZ = vec2f(worldPos.x, worldPos.z);\n let offsetXZ = posXZ - camXZ;\n\n \n let cascade0Size = uniforms.cascadeSizes.x;\n let cascade1Size = uniforms.cascadeSizes.y;\n let cascade2Size = uniforms.cascadeSizes.z;\n\n let dist0 = squircleDistanceXZ(offsetXZ, cascade0Size);\n let dist1 = squircleDistanceXZ(offsetXZ, cascade1Size);\n let dist2 = squircleDistanceXZ(offsetXZ, cascade2Size);\n\n var shadow = 1.0;\n\n \n if (dist0 < 0.95) {\n shadow = sampleCascadeShadow(worldPos, normal, 0);\n } else if (dist1 < 0.95) {\n shadow = sampleCascadeShadow(worldPos, normal, 1);\n } else if (dist2 < 0.95) {\n shadow = sampleCascadeShadow(worldPos, normal, 2);\n }\n\n \n return mix(1.0 - shadowStrength, 1.0, shadow);\n}\n\nfn calculateSpotShadow(worldPos: vec3f, normal: vec3f, slotIndex: i32) -> f32 {\n if (slotIndex < 0 || slotIndex >= MAX_SPOT_SHADOWS) {\n return 1.0; \n }\n\n let bias = uniforms.shadowBias;\n let normalBias = bias * 2.0; \n\n \n let biasedPos = worldPos + normal * normalBias;\n\n \n let lightMatrix = spotMatrices.matrices[slotIndex];\n\n \n let lightSpacePos = lightMatrix * vec4f(biasedPos, 1.0);\n\n \n let w = max(abs(lightSpacePos.w), 0.0001) * sign(lightSpacePos.w + 0.0001);\n let projCoords = lightSpacePos.xyz / w;\n\n \n if (projCoords.z < 0.0 || projCoords.z > 1.0 ||\n abs(projCoords.x) > 1.0 || abs(projCoords.y) > 1.0) {\n return 1.0; \n }\n\n \n let col = slotIndex % SPOT_TILES_PER_ROW;\n let row = slotIndex / SPOT_TILES_PER_ROW;\n\n \n let localUV = vec2f(projCoords.x * 0.5 + 0.5, 0.5 - projCoords.y * 0.5);\n\n \n let tileOffset = vec2f(f32(col), f32(row)) * SPOT_TILE_SIZE;\n let atlasUV = (tileOffset + localUV * SPOT_TILE_SIZE) / vec2f(SPOT_ATLAS_WIDTH, SPOT_ATLAS_HEIGHT);\n\n \n let texelSize = 1.0 / SPOT_TILE_SIZE;\n let currentDepth = clamp(projCoords.z - bias * 3.0, 0.001, 0.999);\n\n var shadowSample = 0.0;\n shadowSample += textureSampleCompareLevel(spotShadowAtlas, spotShadowSampler, atlasUV + vec2f(-texelSize, 0.0), currentDepth);\n shadowSample += textureSampleCompareLevel(spotShadowAtlas, spotShadowSampler, atlasUV + vec2f(texelSize, 0.0), currentDepth);\n shadowSample += textureSampleCompareLevel(spotShadowAtlas, spotShadowSampler, atlasUV + vec2f(0.0, -texelSize), currentDepth);\n shadowSample += textureSampleCompareLevel(spotShadowAtlas, spotShadowSampler, atlasUV + vec2f(0.0, texelSize), currentDepth);\n shadowSample /= 4.0;\n\n return shadowSample;\n}\n\nfn applyLighting(baseColor: vec3f, lighting: vec3f) -> vec3f {\n \n \n return baseColor * lighting;\n}\n\nstruct FragmentOutput {\n @location(0) color: vec4f,\n @builtin(frag_depth) depth: f32,\n}\n\nfn calcSoftFade(fragPos: vec4f, particleLinearDepth: f32) -> f32 {\n if (uniforms.softness <= 0.0) {\n return 1.0;\n }\n\n \n let screenPos = vec2i(fragPos.xy);\n let screenSize = vec2i(uniforms.screenSize);\n\n \n if (screenPos.x < 0 || screenPos.x >= screenSize.x ||\n screenPos.y < 0 || screenPos.y >= screenSize.y) {\n return 1.0;\n }\n\n \n let sceneDepthNorm = textureLoad(depthTexture, screenPos, 0);\n\n \n if (sceneDepthNorm <= 0.0) {\n return 1.0;\n }\n\n \n let sceneDepth = uniforms.near + sceneDepthNorm * (uniforms.far - uniforms.near);\n let particleDepth = uniforms.near + particleLinearDepth * (uniforms.far - uniforms.near);\n\n \n let depthDiff = sceneDepth - particleDepth;\n return saturate(depthDiff / uniforms.softness);\n}\n\n@fragment\nfn fragmentMainAlpha(input: VertexOutput) -> FragmentOutput {\n var output: FragmentOutput;\n\n \n let texColor = textureSample(particleTexture, particleSampler, input.uv);\n\n \n var alpha = texColor.a * input.color.a;\n\n \n alpha *= calcSoftFade(input.position, input.linearDepth);\n\n \n if (alpha < 0.001) {\n discard;\n }\n\n \n let baseColor = texColor.rgb * input.color.rgb;\n let litColor = applyLighting(baseColor, input.lighting);\n\n \n output.color = vec4f(litColor, alpha);\n output.depth = input.linearDepth;\n return output;\n}\n\n@fragment\nfn fragmentMainAdditive(input: VertexOutput) -> FragmentOutput {\n var output: FragmentOutput;\n\n \n let texColor = textureSample(particleTexture, particleSampler, input.uv);\n\n \n var alpha = texColor.a * input.color.a;\n\n \n alpha *= calcSoftFade(input.position, input.linearDepth);\n\n \n if (alpha < 0.001) {\n discard;\n }\n\n \n let baseColor = texColor.rgb * input.color.rgb;\n let litColor = applyLighting(baseColor, input.lighting);\n\n \n \n let rgb = litColor * alpha;\n output.color = vec4f(rgb, alpha);\n output.depth = input.linearDepth;\n return output;\n}";
7506
+ var particle_render_default = "const PI = 3.14159265359;\nconst CASCADE_COUNT = 3;\nconst MAX_EMITTERS = 16u;\nconst MAX_LIGHTS = 64u;\nconst MAX_SPOT_SHADOWS = 8;\n\nconst SPOT_ATLAS_WIDTH: f32 = 2048.0;\nconst SPOT_ATLAS_HEIGHT: f32 = 2048.0;\nconst SPOT_TILE_SIZE: f32 = 512.0;\nconst SPOT_TILES_PER_ROW: i32 = 4;\n\nstruct Particle {\n position: vec3f,\n lifetime: f32,\n velocity: vec3f,\n maxLifetime: f32,\n color: vec4f,\n size: vec2f,\n rotation: f32, \n flags: u32,\n lighting: vec3f, \n lightingPad: f32,\n}\n\nstruct Light {\n enabled: u32,\n position: vec3f,\n color: vec4f,\n direction: vec3f,\n geom: vec4f, \n shadowIndex: i32, \n}\n\nstruct EmitterRenderSettings {\n lit: f32, \n emissive: f32, \n softness: f32,\n zOffset: f32,\n}\n\nstruct SpotShadowMatrices {\n matrices: array<mat4x4<f32>, MAX_SPOT_SHADOWS>,\n}\n\nstruct ParticleUniforms {\n viewMatrix: mat4x4f,\n projectionMatrix: mat4x4f,\n cameraPosition: vec3f,\n time: f32,\n cameraRight: vec3f,\n softness: f32,\n cameraUp: vec3f,\n zOffset: f32,\n screenSize: vec2f,\n near: f32,\n far: f32,\n blendMode: f32, \n lit: f32, \n shadowBias: f32,\n shadowStrength: f32,\n \n lightDir: vec3f,\n shadowMapSize: f32,\n lightColor: vec4f,\n ambientColor: vec4f,\n cascadeSizes: vec4f, \n \n envParams: vec4f, \n \n lightParams: vec4u, \n \n fogColor: vec3f,\n fogEnabled: f32,\n fogDistances: vec3f, \n fogBrightResist: f32,\n fogAlphas: vec3f, \n fogPad1: f32,\n fogHeightFade: vec2f, \n fogDebug: f32, \n fogPad2: f32,\n}\n\nstruct CascadeMatrices {\n matrices: array<mat4x4<f32>, CASCADE_COUNT>,\n}\n\nstruct VertexOutput {\n @builtin(position) position: vec4f,\n @location(0) uv: vec2f,\n @location(1) color: vec4f,\n @location(2) viewZ: f32,\n @location(3) linearDepth: f32, \n @location(4) lighting: vec3f, \n @location(5) @interpolate(flat) emitterIdx: u32, \n @location(6) worldPos: vec3f, \n @location(7) @interpolate(flat) centerViewZ: f32, \n}\n\n@group(0) @binding(0) var<uniform> uniforms: ParticleUniforms;\n@group(0) @binding(1) var<storage, read> particles: array<Particle>;\n@group(0) @binding(2) var particleTexture: texture_2d<f32>;\n@group(0) @binding(3) var particleSampler: sampler;\n@group(0) @binding(4) var depthTexture: texture_depth_2d;\n@group(0) @binding(5) var shadowMapArray: texture_depth_2d_array;\n@group(0) @binding(6) var shadowSampler: sampler_comparison;\n@group(0) @binding(7) var<storage, read> cascadeMatrices: CascadeMatrices;\n@group(0) @binding(8) var<storage, read> emitterSettings: array<EmitterRenderSettings, MAX_EMITTERS>;\n@group(0) @binding(9) var envMap: texture_2d<f32>;\n@group(0) @binding(10) var envSampler: sampler;\n\n@group(0) @binding(11) var<storage, read> lights: array<Light, MAX_LIGHTS>;\n\n@group(0) @binding(12) var spotShadowAtlas: texture_depth_2d;\n@group(0) @binding(13) var spotShadowSampler: sampler_comparison;\n@group(0) @binding(14) var<storage, read> spotMatrices: SpotShadowMatrices;\n\nfn getQuadVertex(vertexId: u32) -> vec2f {\n \n \n \n var corners = array<vec2f, 6>(\n vec2f(-0.5, -0.5), \n vec2f(0.5, -0.5), \n vec2f(-0.5, 0.5), \n vec2f(0.5, -0.5), \n vec2f(0.5, 0.5), \n vec2f(-0.5, 0.5) \n );\n return corners[vertexId];\n}\n\nfn getQuadUV(vertexId: u32) -> vec2f {\n var uvs = array<vec2f, 6>(\n vec2f(0.0, 1.0), \n vec2f(1.0, 1.0), \n vec2f(0.0, 0.0), \n vec2f(1.0, 1.0), \n vec2f(1.0, 0.0), \n vec2f(0.0, 0.0) \n );\n return uvs[vertexId];\n}\n\n@vertex\nfn vertexMain(\n @builtin(vertex_index) vertexIndex: u32,\n @builtin(instance_index) instanceIndex: u32\n) -> VertexOutput {\n var output: VertexOutput;\n\n \n let particle = particles[instanceIndex];\n\n \n let emitterIdx = (particle.flags >> 8u) & 0xFFu;\n output.emitterIdx = emitterIdx;\n\n \n if ((particle.flags & 1u) == 0u || particle.lifetime <= 0.0) {\n \n output.position = vec4f(0.0, 0.0, 0.0, 0.0);\n output.uv = vec2f(0.0, 0.0);\n output.color = vec4f(0.0, 0.0, 0.0, 0.0);\n output.viewZ = 0.0;\n output.linearDepth = 0.0;\n output.lighting = vec3f(0.0);\n output.worldPos = vec3f(0.0);\n output.centerViewZ = 0.0;\n return output;\n }\n\n \n \n let particleIsAdditive = (particle.flags & 2u) != 0u;\n let renderingAdditive = uniforms.blendMode > 0.5;\n\n if (particleIsAdditive != renderingAdditive) {\n \n output.position = vec4f(0.0, 0.0, 0.0, 0.0);\n output.uv = vec2f(0.0, 0.0);\n output.color = vec4f(0.0, 0.0, 0.0, 0.0);\n output.viewZ = 0.0;\n output.linearDepth = 0.0;\n output.lighting = vec3f(0.0);\n output.worldPos = vec3f(0.0);\n output.centerViewZ = 0.0;\n return output;\n }\n\n \n let localVertexId = vertexIndex % 6u;\n var quadPos = getQuadVertex(localVertexId);\n output.uv = getQuadUV(localVertexId);\n\n \n let cosR = cos(particle.rotation);\n let sinR = sin(particle.rotation);\n let rotatedPos = vec2f(\n quadPos.x * cosR - quadPos.y * sinR,\n quadPos.x * sinR + quadPos.y * cosR\n );\n\n \n let particleWorldPos = particle.position;\n\n \n let scaledOffset = rotatedPos * particle.size;\n\n \n let right = uniforms.cameraRight;\n let up = uniforms.cameraUp;\n let billboardPos = particleWorldPos + right * scaledOffset.x + up * scaledOffset.y;\n\n \n let toCamera = normalize(uniforms.cameraPosition - particleWorldPos);\n let offsetPos = billboardPos + toCamera * uniforms.zOffset;\n\n \n let viewPos = uniforms.viewMatrix * vec4f(offsetPos, 1.0);\n output.position = uniforms.projectionMatrix * viewPos;\n output.viewZ = -viewPos.z; \n\n \n let z = -viewPos.z; \n output.linearDepth = (z - uniforms.near) / (uniforms.far - uniforms.near);\n\n \n output.lighting = particle.lighting;\n\n \n output.color = particle.color;\n\n \n output.worldPos = particleWorldPos;\n\n \n \n \n output.centerViewZ = output.viewZ;\n\n return output;\n}\n\nfn SphToUV(n: vec3f) -> vec2f {\n var uv: vec2f;\n uv.x = atan2(-n.x, n.z);\n uv.x = (uv.x + PI / 2.0) / (PI * 2.0) + PI * (28.670 / 360.0);\n uv.y = acos(n.y) / PI;\n return uv;\n}\n\nfn octEncode(n: vec3f) -> vec2f {\n var n2 = n / (abs(n.x) + abs(n.y) + abs(n.z));\n if (n2.y < 0.0) {\n let signX = select(-1.0, 1.0, n2.x >= 0.0);\n let signZ = select(-1.0, 1.0, n2.z >= 0.0);\n n2 = vec3f(\n (1.0 - abs(n2.z)) * signX,\n n2.y,\n (1.0 - abs(n2.x)) * signZ\n );\n }\n return n2.xz * 0.5 + 0.5;\n}\n\nfn getEnvUV(dir: vec3f) -> vec2f {\n if (uniforms.envParams.z > 0.5) {\n return octEncode(dir);\n }\n return SphToUV(dir);\n}\n\nfn getIBLSample(dir: vec3f, lod: f32) -> vec3f {\n let envRGBE = textureSampleLevel(envMap, envSampler, getEnvUV(dir), lod);\n \n let envColor = envRGBE.rgb * pow(2.0, envRGBE.a * 255.0 - 128.0);\n return envColor;\n}\n\nfn squircleDistanceXZ(offset: vec2f, size: f32) -> f32 {\n let normalized = offset / size;\n let absNorm = abs(normalized);\n return pow(pow(absNorm.x, 4.0) + pow(absNorm.y, 4.0), 0.25);\n}\n\nfn sampleCascadeShadow(worldPos: vec3f, normal: vec3f, cascadeIndex: i32) -> f32 {\n let bias = uniforms.shadowBias;\n let shadowMapSize = uniforms.shadowMapSize;\n\n \n let biasedPos = worldPos + normal * bias * 0.5;\n\n \n let lightMatrix = cascadeMatrices.matrices[cascadeIndex];\n\n \n let lightSpacePos = lightMatrix * vec4f(biasedPos, 1.0);\n let projCoords = lightSpacePos.xyz / lightSpacePos.w;\n\n \n let shadowUV = vec2f(projCoords.x * 0.5 + 0.5, 0.5 - projCoords.y * 0.5);\n let currentDepth = projCoords.z - bias;\n\n \n let inBoundsX = shadowUV.x >= 0.0 && shadowUV.x <= 1.0;\n let inBoundsY = shadowUV.y >= 0.0 && shadowUV.y <= 1.0;\n let inBoundsZ = currentDepth >= 0.0 && currentDepth <= 1.0;\n\n if (!inBoundsX || !inBoundsY || !inBoundsZ) {\n return 1.0; \n }\n\n let clampedUV = clamp(shadowUV, vec2f(0.001), vec2f(0.999));\n let clampedDepth = clamp(currentDepth, 0.001, 0.999);\n\n \n let texelSize = 1.0 / shadowMapSize;\n var shadow = 0.0;\n shadow += textureSampleCompareLevel(shadowMapArray, shadowSampler, clampedUV + vec2f(-texelSize, 0.0), cascadeIndex, clampedDepth);\n shadow += textureSampleCompareLevel(shadowMapArray, shadowSampler, clampedUV + vec2f(texelSize, 0.0), cascadeIndex, clampedDepth);\n shadow += textureSampleCompareLevel(shadowMapArray, shadowSampler, clampedUV + vec2f(0.0, -texelSize), cascadeIndex, clampedDepth);\n shadow += textureSampleCompareLevel(shadowMapArray, shadowSampler, clampedUV + vec2f(0.0, texelSize), cascadeIndex, clampedDepth);\n shadow /= 4.0;\n\n return shadow;\n}\n\nfn calculateParticleShadow(worldPos: vec3f, normal: vec3f) -> f32 {\n let shadowStrength = uniforms.shadowStrength;\n\n \n let camXZ = vec2f(uniforms.cameraPosition.x, uniforms.cameraPosition.z);\n let posXZ = vec2f(worldPos.x, worldPos.z);\n let offsetXZ = posXZ - camXZ;\n\n \n let cascade0Size = uniforms.cascadeSizes.x;\n let cascade1Size = uniforms.cascadeSizes.y;\n let cascade2Size = uniforms.cascadeSizes.z;\n\n let dist0 = squircleDistanceXZ(offsetXZ, cascade0Size);\n let dist1 = squircleDistanceXZ(offsetXZ, cascade1Size);\n let dist2 = squircleDistanceXZ(offsetXZ, cascade2Size);\n\n var shadow = 1.0;\n\n \n if (dist0 < 0.95) {\n shadow = sampleCascadeShadow(worldPos, normal, 0);\n } else if (dist1 < 0.95) {\n shadow = sampleCascadeShadow(worldPos, normal, 1);\n } else if (dist2 < 0.95) {\n shadow = sampleCascadeShadow(worldPos, normal, 2);\n }\n\n \n return mix(1.0 - shadowStrength, 1.0, shadow);\n}\n\nfn calculateSpotShadow(worldPos: vec3f, normal: vec3f, slotIndex: i32) -> f32 {\n if (slotIndex < 0 || slotIndex >= MAX_SPOT_SHADOWS) {\n return 1.0; \n }\n\n let bias = uniforms.shadowBias;\n let normalBias = bias * 2.0; \n\n \n let biasedPos = worldPos + normal * normalBias;\n\n \n let lightMatrix = spotMatrices.matrices[slotIndex];\n\n \n let lightSpacePos = lightMatrix * vec4f(biasedPos, 1.0);\n\n \n let w = max(abs(lightSpacePos.w), 0.0001) * sign(lightSpacePos.w + 0.0001);\n let projCoords = lightSpacePos.xyz / w;\n\n \n if (projCoords.z < 0.0 || projCoords.z > 1.0 ||\n abs(projCoords.x) > 1.0 || abs(projCoords.y) > 1.0) {\n return 1.0; \n }\n\n \n let col = slotIndex % SPOT_TILES_PER_ROW;\n let row = slotIndex / SPOT_TILES_PER_ROW;\n\n \n let localUV = vec2f(projCoords.x * 0.5 + 0.5, 0.5 - projCoords.y * 0.5);\n\n \n let tileOffset = vec2f(f32(col), f32(row)) * SPOT_TILE_SIZE;\n let atlasUV = (tileOffset + localUV * SPOT_TILE_SIZE) / vec2f(SPOT_ATLAS_WIDTH, SPOT_ATLAS_HEIGHT);\n\n \n let texelSize = 1.0 / SPOT_TILE_SIZE;\n let currentDepth = clamp(projCoords.z - bias * 3.0, 0.001, 0.999);\n\n var shadowSample = 0.0;\n shadowSample += textureSampleCompareLevel(spotShadowAtlas, spotShadowSampler, atlasUV + vec2f(-texelSize, 0.0), currentDepth);\n shadowSample += textureSampleCompareLevel(spotShadowAtlas, spotShadowSampler, atlasUV + vec2f(texelSize, 0.0), currentDepth);\n shadowSample += textureSampleCompareLevel(spotShadowAtlas, spotShadowSampler, atlasUV + vec2f(0.0, -texelSize), currentDepth);\n shadowSample += textureSampleCompareLevel(spotShadowAtlas, spotShadowSampler, atlasUV + vec2f(0.0, texelSize), currentDepth);\n shadowSample /= 4.0;\n\n return shadowSample;\n}\n\nfn applyLighting(baseColor: vec3f, lighting: vec3f) -> vec3f {\n \n \n return baseColor * lighting;\n}\n\nstruct FragmentOutput {\n @location(0) color: vec4f,\n @builtin(frag_depth) depth: f32,\n}\n\nfn calcSoftFade(fragPos: vec4f, particleLinearDepth: f32) -> f32 {\n if (uniforms.softness <= 0.0) {\n return 1.0;\n }\n\n \n let screenPos = vec2i(fragPos.xy);\n let screenSize = vec2i(uniforms.screenSize);\n\n \n if (screenPos.x < 0 || screenPos.x >= screenSize.x ||\n screenPos.y < 0 || screenPos.y >= screenSize.y) {\n return 1.0;\n }\n\n \n let sceneDepthNorm = textureLoad(depthTexture, screenPos, 0);\n\n \n if (sceneDepthNorm <= 0.0) {\n return 1.0;\n }\n\n \n let sceneDepth = uniforms.near + sceneDepthNorm * (uniforms.far - uniforms.near);\n let particleDepth = uniforms.near + particleLinearDepth * (uniforms.far - uniforms.near);\n\n \n let depthDiff = sceneDepth - particleDepth;\n return saturate(depthDiff / uniforms.softness);\n}\n\nfn calcFog(cameraDistance: f32, worldPosY: f32) -> f32 {\n if (uniforms.fogEnabled < 0.5) {\n return 0.0;\n }\n\n \n var distanceFog: f32;\n let d0 = uniforms.fogDistances.x;\n let d1 = uniforms.fogDistances.y;\n let d2 = uniforms.fogDistances.z;\n let a0 = uniforms.fogAlphas.x;\n let a1 = uniforms.fogAlphas.y;\n let a2 = uniforms.fogAlphas.z;\n\n if (cameraDistance <= d0) {\n distanceFog = a0;\n } else if (cameraDistance <= d1) {\n let t = (cameraDistance - d0) / max(d1 - d0, 0.001);\n distanceFog = mix(a0, a1, t);\n } else if (cameraDistance <= d2) {\n let t = (cameraDistance - d1) / max(d2 - d1, 0.001);\n distanceFog = mix(a1, a2, t);\n } else {\n distanceFog = a2;\n }\n\n \n let bottomY = uniforms.fogHeightFade.x;\n let topY = uniforms.fogHeightFade.y;\n var heightFactor = clamp((worldPosY - bottomY) / max(topY - bottomY, 0.001), 0.0, 1.0);\n if (worldPosY < bottomY) {\n heightFactor = 0.0;\n }\n\n return distanceFog * (1.0 - heightFactor);\n}\n\nfn applyFog(color: vec3f, cameraDistance: f32, worldPosY: f32) -> vec3f {\n let fogAlpha = calcFog(cameraDistance, worldPosY);\n if (fogAlpha <= 0.0) {\n return color;\n }\n\n return mix(color, uniforms.fogColor, fogAlpha);\n}\n\n@fragment\nfn fragmentMainAlpha(input: VertexOutput) -> FragmentOutput {\n var output: FragmentOutput;\n\n \n let texColor = textureSample(particleTexture, particleSampler, input.uv);\n\n \n var alpha = texColor.a * input.color.a;\n\n \n alpha *= calcSoftFade(input.position, input.linearDepth);\n\n \n if (alpha < 0.001) {\n discard;\n }\n\n \n let baseColor = texColor.rgb * input.color.rgb;\n let litColor = applyLighting(baseColor, input.lighting);\n\n \n let centerViewZ = input.centerViewZ;\n\n \n \n if (uniforms.fogDebug > 0.5) {\n \n if (uniforms.fogDebug < 1.5) {\n let dist = clamp(centerViewZ / 100.0, 0.0, 1.0);\n output.color = vec4f(vec3f(dist), 1.0);\n }\n \n \n else if (uniforms.fogDebug < 2.5) {\n let r = clamp(centerViewZ / 10.0, 0.0, 1.0);\n let g = clamp(centerViewZ / 100.0, 0.0, 1.0);\n let b = clamp(centerViewZ / 1000.0, 0.0, 1.0);\n output.color = vec4f(r, g, b, 1.0);\n }\n \n \n else if (uniforms.fogDebug < 3.5) {\n let center = clamp(centerViewZ / 100.0, 0.0, 1.0);\n let vertex = clamp(input.viewZ / 100.0, 0.0, 1.0);\n let diff = abs(center - vertex);\n output.color = vec4f(center, vertex, diff * 10.0, 1.0);\n }\n \n else if (uniforms.fogDebug < 4.5) {\n let r = clamp(abs(input.worldPos.x) / 100.0, 0.0, 1.0);\n let g = clamp(abs(input.worldPos.y) / 100.0, 0.0, 1.0);\n let b = clamp(abs(input.worldPos.z) / 100.0, 0.0, 1.0);\n output.color = vec4f(r, g, b, 1.0);\n }\n \n else {\n output.color = vec4f(uniforms.fogColor, 1.0);\n }\n output.depth = input.linearDepth;\n return output;\n }\n\n let foggedColor = applyFog(litColor, centerViewZ, input.worldPos.y);\n\n output.color = vec4f(foggedColor, alpha);\n output.depth = input.linearDepth;\n return output;\n}\n\n@fragment\nfn fragmentMainAdditive(input: VertexOutput) -> FragmentOutput {\n var output: FragmentOutput;\n\n \n let texColor = textureSample(particleTexture, particleSampler, input.uv);\n\n \n var alpha = texColor.a * input.color.a;\n\n \n alpha *= calcSoftFade(input.position, input.linearDepth);\n\n \n if (alpha < 0.001) {\n discard;\n }\n\n \n let baseColor = texColor.rgb * input.color.rgb;\n let litColor = applyLighting(baseColor, input.lighting);\n\n \n let centerViewZ = input.centerViewZ;\n\n \n \n if (uniforms.fogDebug > 0.5) {\n \n if (uniforms.fogDebug < 1.5) {\n let dist = clamp(centerViewZ / 100.0, 0.0, 1.0);\n output.color = vec4f(vec3f(dist), 1.0);\n }\n \n else if (uniforms.fogDebug < 2.5) {\n let r = clamp(centerViewZ / 10.0, 0.0, 1.0);\n let g = clamp(centerViewZ / 100.0, 0.0, 1.0);\n let b = clamp(centerViewZ / 1000.0, 0.0, 1.0);\n output.color = vec4f(r, g, b, 1.0);\n }\n \n else if (uniforms.fogDebug < 3.5) {\n let center = clamp(centerViewZ / 100.0, 0.0, 1.0);\n let vertex = clamp(input.viewZ / 100.0, 0.0, 1.0);\n let diff = abs(center - vertex);\n output.color = vec4f(center, vertex, diff * 10.0, 1.0);\n }\n \n else if (uniforms.fogDebug < 4.5) {\n let r = clamp(abs(input.worldPos.x) / 100.0, 0.0, 1.0);\n let g = clamp(abs(input.worldPos.y) / 100.0, 0.0, 1.0);\n let b = clamp(abs(input.worldPos.z) / 100.0, 0.0, 1.0);\n output.color = vec4f(r, g, b, 1.0);\n }\n \n else {\n output.color = vec4f(uniforms.fogColor, 1.0);\n }\n output.depth = input.linearDepth;\n return output;\n }\n\n \n \n let fogAlpha = calcFog(centerViewZ, input.worldPos.y);\n let fadedColor = litColor * (1.0 - fogAlpha);\n\n \n let rgb = fadedColor * alpha;\n output.color = vec4f(rgb, alpha);\n output.depth = input.linearDepth;\n return output;\n}";
7264
7507
  class ParticlePass extends BasePass {
7265
7508
  constructor(engine = null) {
7266
7509
  super("Particles", engine);
@@ -7798,7 +8041,7 @@ class ParticlePass extends BasePass {
7798
8041
  uniformData[87] = 0;
7799
8042
  uniformData[88] = fogHeightFade[0];
7800
8043
  uniformData[89] = fogHeightFade[1];
7801
- uniformData[90] = 0;
8044
+ uniformData[90] = fogSettings.debug ?? 0;
7802
8045
  uniformData[91] = 0;
7803
8046
  const emitterRenderData = new Float32Array(16 * 4);
7804
8047
  for (let i = 0; i < Math.min(emitters.length, 16); i++) {
@@ -7841,7 +8084,8 @@ class ParticlePass extends BasePass {
7841
8084
  if (hasAlpha) {
7842
8085
  uniformData[48] = 0;
7843
8086
  device.queue.writeBuffer(this.renderUniformBuffer, 0, uniformData);
7844
- const renderPass = commandEncoder.beginRenderPass({
8087
+ const alphaEncoder = device.createCommandEncoder({ label: "Particle Alpha Pass" });
8088
+ const renderPass = alphaEncoder.beginRenderPass({
7845
8089
  colorAttachments: [{
7846
8090
  view: this.outputTexture.view,
7847
8091
  loadOp: "load",
@@ -7857,11 +8101,13 @@ class ParticlePass extends BasePass {
7857
8101
  renderPass.setBindGroup(0, renderBindGroup);
7858
8102
  renderPass.draw(6, maxParticles, 0, 0);
7859
8103
  renderPass.end();
8104
+ device.queue.submit([alphaEncoder.finish()]);
7860
8105
  }
7861
8106
  if (hasAdditive) {
7862
8107
  uniformData[48] = 1;
7863
8108
  device.queue.writeBuffer(this.renderUniformBuffer, 0, uniformData);
7864
- const renderPass = commandEncoder.beginRenderPass({
8109
+ const additiveEncoder = device.createCommandEncoder({ label: "Particle Additive Pass" });
8110
+ const renderPass = additiveEncoder.beginRenderPass({
7865
8111
  colorAttachments: [{
7866
8112
  view: this.outputTexture.view,
7867
8113
  loadOp: "load",
@@ -7877,6 +8123,7 @@ class ParticlePass extends BasePass {
7877
8123
  renderPass.setBindGroup(0, renderBindGroup);
7878
8124
  renderPass.draw(6, maxParticles, 0, 0);
7879
8125
  renderPass.end();
8126
+ device.queue.submit([additiveEncoder.finish()]);
7880
8127
  }
7881
8128
  }
7882
8129
  async _resize(width, height) {
@@ -7918,6 +8165,10 @@ class FogPass extends BasePass {
7918
8165
  get fogBrightResist() {
7919
8166
  return this.settings?.environment?.fog?.brightResist ?? 0.8;
7920
8167
  }
8168
+ get fogDebug() {
8169
+ return this.settings?.environment?.fog?.debug ?? 0;
8170
+ }
8171
+ // 0=off, 1=show fogAlpha, 2=show distance, 3=show heightFactor
7921
8172
  /**
7922
8173
  * Set the input texture (HDR lighting output)
7923
8174
  */
@@ -7994,6 +8245,8 @@ class FogPass extends BasePass {
7994
8245
  brightResist: f32, // float 47
7995
8246
  heightFade: vec2f, // floats 48-49
7996
8247
  screenSize: vec2f, // floats 50-51
8248
+ debug: f32, // float 52: 0=off, 1=fogAlpha, 2=distance, 3=heightFactor
8249
+ _pad: vec3f, // floats 53-55
7997
8250
  }
7998
8251
 
7999
8252
  @group(0) @binding(0) var<uniform> uniforms: Uniforms;
@@ -8122,6 +8375,26 @@ class FogPass extends BasePass {
8122
8375
  let brightnessResist = clamp((luminance - 1.0) / 2.0, 0.0, 1.0);
8123
8376
  fogAlpha *= (1.0 - brightnessResist * uniforms.brightResist);
8124
8377
 
8378
+ // Debug output
8379
+ let debugMode = i32(uniforms.debug);
8380
+ if (debugMode == 1) {
8381
+ // Show fog alpha as grayscale
8382
+ return vec4f(vec3f(fogAlpha), 1.0);
8383
+ } else if (debugMode == 2) {
8384
+ // Show distance (normalized to 0-100m range)
8385
+ let normDist = clamp(cameraDistance / 100.0, 0.0, 1.0);
8386
+ return vec4f(vec3f(normDist), 1.0);
8387
+ } else if (debugMode == 3) {
8388
+ // Show height factor
8389
+ return vec4f(vec3f(heightFactor), 1.0);
8390
+ } else if (debugMode == 4) {
8391
+ // Show distance fog (before height fade)
8392
+ return vec4f(vec3f(distanceFog), 1.0);
8393
+ } else if (debugMode == 5) {
8394
+ // Show the actual fog color being used (to verify it matches particles)
8395
+ return vec4f(uniforms.fogColor, 1.0);
8396
+ }
8397
+
8125
8398
  // Apply fog
8126
8399
  let foggedColor = mix(color.rgb, uniforms.fogColor, fogAlpha);
8127
8400
 
@@ -8196,6 +8469,7 @@ class FogPass extends BasePass {
8196
8469
  uniformData[49] = heightFade[1];
8197
8470
  uniformData[50] = this.width;
8198
8471
  uniformData[51] = this.height;
8472
+ uniformData[52] = this.fogDebug;
8199
8473
  device.queue.writeBuffer(this.uniformBuffer, 0, uniformData);
8200
8474
  const commandEncoder = device.createCommandEncoder({ label: "Fog Pass" });
8201
8475
  const renderPass = commandEncoder.beginRenderPass({
@@ -8594,6 +8868,7 @@ class HiZPass extends BasePass {
8594
8868
  this.screenWidth = 0;
8595
8869
  this.screenHeight = 0;
8596
8870
  this._destroyed = false;
8871
+ this._warmupFramesRemaining = 5;
8597
8872
  }
8598
8873
  /**
8599
8874
  * Set the depth texture to read from (from GBuffer)
@@ -8602,6 +8877,18 @@ class HiZPass extends BasePass {
8602
8877
  setDepthTexture(depth) {
8603
8878
  this.depthTexture = depth;
8604
8879
  }
8880
+ /**
8881
+ * Invalidate occlusion culling data and reset warmup period.
8882
+ * Call this after engine creation, scene loading, or major camera changes
8883
+ * to prevent incorrect occlusion culling with stale data.
8884
+ */
8885
+ invalidate() {
8886
+ this.hasValidHistory = false;
8887
+ this.hizDataReady = false;
8888
+ this._warmupFramesRemaining = 5;
8889
+ vec3$1.set(this.lastCameraPosition, 0, 0, 0);
8890
+ vec3$1.set(this.lastCameraDirection, 0, 0, 0);
8891
+ }
8605
8892
  async _init() {
8606
8893
  const { device, canvas } = this.engine;
8607
8894
  await this._createResources(canvas.width, canvas.height);
@@ -8685,6 +8972,7 @@ class HiZPass extends BasePass {
8685
8972
  this._destroyed = false;
8686
8973
  this.hizDataReady = false;
8687
8974
  this.pendingReadback = null;
8975
+ this._warmupFramesRemaining = 5;
8688
8976
  }
8689
8977
  /**
8690
8978
  * Check if camera has moved significantly, requiring HiZ invalidation
@@ -8718,6 +9006,9 @@ class HiZPass extends BasePass {
8718
9006
  const { device } = this.engine;
8719
9007
  const { camera } = context;
8720
9008
  this._frameCounter++;
9009
+ if (this._warmupFramesRemaining > 0) {
9010
+ this._warmupFramesRemaining--;
9011
+ }
8721
9012
  if (!this.settings?.occlusionCulling?.enabled) {
8722
9013
  return;
8723
9014
  }
@@ -8868,6 +9159,9 @@ class HiZPass extends BasePass {
8868
9159
  */
8869
9160
  testSphereOcclusion(bsphere, viewProj, near, far, cameraPos) {
8870
9161
  this.debugStats.tested++;
9162
+ if (this._warmupFramesRemaining > 0) {
9163
+ return false;
9164
+ }
8871
9165
  if (!this.hizDataReady || !this.hasValidHistory) {
8872
9166
  return false;
8873
9167
  }
@@ -9281,6 +9575,8 @@ class SSGITilePass extends BasePass {
9281
9575
  this.tilePropagateBuffer = null;
9282
9576
  this.tileCountX = 0;
9283
9577
  this.tileCountY = 0;
9578
+ this.renderWidth = 0;
9579
+ this.renderHeight = 0;
9284
9580
  this.prevHDRTexture = null;
9285
9581
  this.emissiveTexture = null;
9286
9582
  this.uniformBuffer = null;
@@ -9309,6 +9605,8 @@ class SSGITilePass extends BasePass {
9309
9605
  }
9310
9606
  async _createResources(width, height) {
9311
9607
  const { device } = this.engine;
9608
+ this.renderWidth = width;
9609
+ this.renderHeight = height;
9312
9610
  this.tileCountX = Math.ceil(width / TILE_SIZE$1);
9313
9611
  this.tileCountY = Math.ceil(height / TILE_SIZE$1);
9314
9612
  const totalTiles = this.tileCountX * this.tileCountY;
@@ -9376,13 +9674,13 @@ class SSGITilePass extends BasePass {
9376
9674
  this._needsRebuild = false;
9377
9675
  }
9378
9676
  async _execute(context) {
9379
- const { device, canvas } = this.engine;
9677
+ const { device } = this.engine;
9380
9678
  const ssgiSettings = this.settings?.ssgi;
9381
9679
  if (!ssgiSettings?.enabled) {
9382
9680
  return;
9383
9681
  }
9384
9682
  if (this._needsRebuild) {
9385
- await this._createResources(canvas.width, canvas.height);
9683
+ await this._createResources(this.renderWidth, this.renderHeight);
9386
9684
  }
9387
9685
  if (!this.prevHDRTexture || !this.emissiveTexture) {
9388
9686
  return;
@@ -9390,8 +9688,8 @@ class SSGITilePass extends BasePass {
9390
9688
  if (!this.accumulatePipeline || !this.propagatePipeline) {
9391
9689
  return;
9392
9690
  }
9393
- const width = canvas.width;
9394
- const height = canvas.height;
9691
+ const width = this.renderWidth;
9692
+ const height = this.renderHeight;
9395
9693
  const emissiveBoost = ssgiSettings.emissiveBoost ?? 2;
9396
9694
  const maxBrightness = ssgiSettings.maxBrightness ?? 4;
9397
9695
  device.queue.writeBuffer(this.uniformBuffer, 0, new Float32Array([
@@ -9587,7 +9885,7 @@ class SSGIPass extends BasePass {
9587
9885
  if (!this.pipeline) {
9588
9886
  return;
9589
9887
  }
9590
- this._updateUniforms(ssgiSettings, canvas.width, canvas.height);
9888
+ this._updateUniforms(ssgiSettings, this.width * 2, this.height * 2);
9591
9889
  const bindGroup = device.createBindGroup({
9592
9890
  label: "ssgiBindGroup",
9593
9891
  layout: this.bindGroupLayout,
@@ -10059,9 +10357,11 @@ class BloomPass extends BasePass {
10059
10357
  const scale = this.bloomScale;
10060
10358
  const bloomWidth = Math.max(1, Math.floor(width * scale));
10061
10359
  const bloomHeight = Math.max(1, Math.floor(height * scale));
10360
+ if (this.bloomWidth !== bloomWidth || this.bloomHeight !== bloomHeight) {
10361
+ console.log(`Bloom: ${width}x${height} -> ${bloomWidth}x${bloomHeight} (scale: ${scale})`);
10362
+ }
10062
10363
  this.bloomWidth = bloomWidth;
10063
10364
  this.bloomHeight = bloomHeight;
10064
- console.log(`Bloom: ${width}x${height} -> ${bloomWidth}x${bloomHeight} (scale: ${scale})`);
10065
10365
  const createBloomTexture = (label) => {
10066
10366
  const texture = device.createTexture({
10067
10367
  label,
@@ -11030,150 +11330,842 @@ fn fragmentMain(input: VertexOutput) -> @location(0) vec4f {
11030
11330
  this.pipelineCache.clear();
11031
11331
  }
11032
11332
  }
11033
- var postproc_default = "struct VertexOutput {\n @builtin(position) position : vec4<f32>,\n @location(0) uv : vec2<f32>,\n}\n\nstruct Uniforms {\n canvasSize: vec2f,\n noiseParams: vec4f, \n ditherParams: vec4f, \n bloomParams: vec4f, \n}\n\n@group(0) @binding(0) var<uniform> uniforms: Uniforms;\n@group(0) @binding(1) var inputTexture : texture_2d<f32>;\n@group(0) @binding(2) var inputSampler : sampler;\n@group(0) @binding(3) var noiseTexture : texture_2d<f32>;\n@group(0) @binding(4) var noiseSampler : sampler;\n@group(0) @binding(5) var bloomTexture : texture_2d<f32>;\n@group(0) @binding(6) var bloomSampler : sampler;\n@group(0) @binding(7) var guiTexture : texture_2d<f32>;\n@group(0) @binding(8) var guiSampler : sampler;\n\nconst FXAA_EDGE_THRESHOLD: f32 = 0.125; \nconst FXAA_EDGE_THRESHOLD_MIN: f32 = 0.0156; \nconst FXAA_SUBPIX_QUALITY: f32 = 1.0; \n\nfn sampleNoise(screenPos: vec2f) -> f32 {\n let noiseSize = i32(uniforms.noiseParams.x);\n let noiseOffsetX = i32(uniforms.noiseParams.y * f32(noiseSize));\n let noiseOffsetY = i32(uniforms.noiseParams.z * f32(noiseSize));\n\n let texCoord = vec2i(\n (i32(screenPos.x) + noiseOffsetX) % noiseSize,\n (i32(screenPos.y) + noiseOffsetY) % noiseSize\n );\n return textureLoad(noiseTexture, texCoord, 0).r;\n}\n\nfn rgb2luma(rgb: vec3f) -> f32 {\n return dot(rgb, vec3f(0.299, 0.587, 0.114));\n}\n\nfn loadPixel(coord: vec2i) -> vec3f {\n let size = vec2i(textureDimensions(inputTexture, 0));\n let clampedCoord = clamp(coord, vec2i(0), size - vec2i(1));\n return textureLoad(inputTexture, clampedCoord, 0).rgb;\n}\n\nfn sampleBilinear(uv: vec2f) -> vec3f {\n let texSize = vec2f(textureDimensions(inputTexture, 0));\n let texelPos = uv * texSize - 0.5;\n let baseCoord = vec2i(floor(texelPos));\n let frac = fract(texelPos);\n\n let c00 = loadPixel(baseCoord);\n let c10 = loadPixel(baseCoord + vec2i(1, 0));\n let c01 = loadPixel(baseCoord + vec2i(0, 1));\n let c11 = loadPixel(baseCoord + vec2i(1, 1));\n\n let c0 = mix(c00, c10, frac.x);\n let c1 = mix(c01, c11, frac.x);\n return mix(c0, c1, frac.y);\n}\n\nfn fxaa(uv: vec2f) -> vec3f {\n let texSize = vec2f(textureDimensions(inputTexture, 0));\n let pixelCoord = vec2i(uv * texSize);\n\n \n let rgbM = loadPixel(pixelCoord);\n let rgbN = loadPixel(pixelCoord + vec2i(0, -1));\n let rgbS = loadPixel(pixelCoord + vec2i(0, 1));\n let rgbW = loadPixel(pixelCoord + vec2i(-1, 0));\n let rgbE = loadPixel(pixelCoord + vec2i(1, 0));\n let rgbNW = loadPixel(pixelCoord + vec2i(-1, -1));\n let rgbNE = loadPixel(pixelCoord + vec2i(1, -1));\n let rgbSW = loadPixel(pixelCoord + vec2i(-1, 1));\n let rgbSE = loadPixel(pixelCoord + vec2i(1, 1));\n\n \n let lumaM = rgb2luma(rgbM);\n let lumaN = rgb2luma(rgbN);\n let lumaS = rgb2luma(rgbS);\n let lumaW = rgb2luma(rgbW);\n let lumaE = rgb2luma(rgbE);\n let lumaNW = rgb2luma(rgbNW);\n let lumaNE = rgb2luma(rgbNE);\n let lumaSW = rgb2luma(rgbSW);\n let lumaSE = rgb2luma(rgbSE);\n\n \n let lumaMin = min(lumaM, min(min(lumaN, lumaS), min(lumaW, lumaE)));\n let lumaMax = max(lumaM, max(max(lumaN, lumaS), max(lumaW, lumaE)));\n let lumaRange = lumaMax - lumaMin;\n\n \n let isEdge = lumaRange >= max(FXAA_EDGE_THRESHOLD_MIN, lumaMax * FXAA_EDGE_THRESHOLD);\n\n \n let lumaL = (lumaN + lumaS + lumaW + lumaE) * 0.25;\n let rangeL = abs(lumaL - lumaM);\n var blendL = max(0.0, (rangeL / max(lumaRange, 0.0001)) - 0.25) * (1.0 / 0.75);\n blendL = min(1.0, blendL) * blendL * FXAA_SUBPIX_QUALITY;\n\n \n let edgeHorz = abs((lumaNW + lumaNE) - 2.0 * lumaN) +\n 2.0 * abs((lumaW + lumaE) - 2.0 * lumaM) +\n abs((lumaSW + lumaSE) - 2.0 * lumaS);\n let edgeVert = abs((lumaNW + lumaSW) - 2.0 * lumaW) +\n 2.0 * abs((lumaN + lumaS) - 2.0 * lumaM) +\n abs((lumaNE + lumaSE) - 2.0 * lumaE);\n let isHorizontal = edgeHorz >= edgeVert;\n\n \n let luma1 = select(lumaE, lumaS, isHorizontal);\n let luma2 = select(lumaW, lumaN, isHorizontal);\n let gradient1 = abs(luma1 - lumaM);\n let gradient2 = abs(luma2 - lumaM);\n let is1Steepest = gradient1 >= gradient2;\n let gradientScaled = 0.25 * max(gradient1, gradient2);\n\n \n let stepSign = select(-1.0, 1.0, is1Steepest);\n let lumaLocalAverage = 0.5 * (select(luma2, luma1, is1Steepest) + lumaM);\n\n \n let searchDir = select(vec2i(0, 1), vec2i(1, 0), isHorizontal);\n\n let luma1_1 = rgb2luma(loadPixel(pixelCoord - searchDir)) - lumaLocalAverage;\n let luma2_1 = rgb2luma(loadPixel(pixelCoord + searchDir)) - lumaLocalAverage;\n let luma1_2 = rgb2luma(loadPixel(pixelCoord - searchDir * 2)) - lumaLocalAverage;\n let luma2_2 = rgb2luma(loadPixel(pixelCoord + searchDir * 2)) - lumaLocalAverage;\n let luma1_3 = rgb2luma(loadPixel(pixelCoord - searchDir * 3)) - lumaLocalAverage;\n let luma2_3 = rgb2luma(loadPixel(pixelCoord + searchDir * 3)) - lumaLocalAverage;\n let luma1_4 = rgb2luma(loadPixel(pixelCoord - searchDir * 4)) - lumaLocalAverage;\n let luma2_4 = rgb2luma(loadPixel(pixelCoord + searchDir * 4)) - lumaLocalAverage;\n\n \n let reached1_1 = abs(luma1_1) >= gradientScaled;\n let reached1_2 = abs(luma1_2) >= gradientScaled;\n let reached1_3 = abs(luma1_3) >= gradientScaled;\n let reached2_1 = abs(luma2_1) >= gradientScaled;\n let reached2_2 = abs(luma2_2) >= gradientScaled;\n let reached2_3 = abs(luma2_3) >= gradientScaled;\n\n \n let dist1 = select(select(select(4.0, 3.0, reached1_3), 2.0, reached1_2), 1.0, reached1_1);\n let dist2 = select(select(select(4.0, 3.0, reached2_3), 2.0, reached2_2), 1.0, reached2_1);\n\n \n let lumaEnd1 = select(select(select(luma1_4, luma1_3, reached1_3), luma1_2, reached1_2), luma1_1, reached1_1);\n let lumaEnd2 = select(select(select(luma2_4, luma2_3, reached2_3), luma2_2, reached2_2), luma2_1, reached2_1);\n\n \n let distFinal = min(dist1, dist2);\n let edgeThickness = dist1 + dist2;\n let lumaEndCloser = select(lumaEnd2, lumaEnd1, dist1 < dist2);\n let correctVariation = (lumaEndCloser < 0.0) != (lumaM < lumaLocalAverage);\n var pixelOffset = select(0.0, -distFinal / max(edgeThickness, 0.0001) + 0.5, correctVariation);\n\n \n let finalOffset = max(max(pixelOffset, blendL), 0.5);\n\n \n let inverseVP = 1.0 / texSize;\n var finalUv = uv;\n let offsetAmount = finalOffset * stepSign;\n finalUv.x += select(offsetAmount * inverseVP.x, 0.0, isHorizontal);\n finalUv.y += select(0.0, offsetAmount * inverseVP.y, isHorizontal);\n\n \n let offsetColor = sampleBilinear(finalUv);\n\n \n \n let perpDir = select(vec2i(0, 1), vec2i(1, 0), isHorizontal);\n let perpColor1 = loadPixel(pixelCoord + perpDir);\n let perpColor2 = loadPixel(pixelCoord - perpDir);\n let neighborAvg = (perpColor1 + perpColor2) * 0.5;\n\n \n let fxaaColor = mix(mix(offsetColor, neighborAvg, 0.7), rgbM, 0.1);\n\n \n return select(rgbM, fxaaColor, isEdge);\n}\n\nfn sampleBloom(uv: vec2f, sceneBrightness: f32) -> vec3f {\n let bloom = textureSample(bloomTexture, bloomSampler, uv).rgb;\n\n \n \n let threshold = 0.5; \n let mask = saturate(1.0 - (sceneBrightness - threshold) / (1.0 - threshold));\n\n return bloom * mask * mask; \n}\n\nfn aces_tone_map(hdr: vec3<f32>) -> vec3<f32> {\n let m1 = mat3x3(\n 0.59719, 0.07600, 0.02840,\n 0.35458, 0.90834, 0.13383,\n 0.04823, 0.01566, 0.83777,\n );\n let m2 = mat3x3(\n 1.60475, -0.10208, -0.00327,\n -0.53108, 1.10813, -0.07276,\n -0.07367, -0.00605, 1.07602,\n );\n let v = m1 * hdr;\n let a = v * (v + 0.0245786) - 0.000090537;\n let b = v * (0.983729 * v + 0.4329510) + 0.238081;\n return clamp(m2 * (a / b), vec3(0.0), vec3(1.0));\n}\n\n@vertex\nfn vertexMain(@builtin(vertex_index) vertexIndex : u32) -> VertexOutput {\n var output : VertexOutput;\n let x = f32(vertexIndex & 1u) * 4.0 - 1.0;\n let y = f32(vertexIndex >> 1u) * 4.0 - 1.0;\n output.position = vec4<f32>(x, y, 0.0, 1.0);\n output.uv = vec2<f32>((x + 1.0) * 0.5, (1.0 - y) * 0.5);\n return output;\n}\n\n@fragment\nfn fragmentMain(input: VertexOutput) -> @location(0) vec4<f32> {\n \n let fxaaEnabled = uniforms.noiseParams.w > 0.5;\n let fxaaColor = fxaa(input.uv);\n let directColor = textureSample(inputTexture, inputSampler, input.uv).rgb;\n var color = select(directColor, fxaaColor, fxaaEnabled);\n\n \n \n if (uniforms.bloomParams.x > 0.5) {\n let sceneBrightness = rgb2luma(color);\n let bloom = sampleBloom(input.uv, sceneBrightness);\n color += bloom * uniforms.bloomParams.y; \n }\n\n var sdr = aces_tone_map(color);\n\n \n \n let gui = textureSample(guiTexture, guiSampler, input.uv);\n sdr = sdr * (1.0 - gui.a) + gui.rgb;\n\n \n \n if (uniforms.ditherParams.x > 0.5 && uniforms.noiseParams.x > 0.0) {\n let levels = uniforms.ditherParams.y;\n let noise = sampleNoise(input.position.xy);\n\n \n \n let dither = noise - 0.5;\n sdr = floor(sdr * (levels - 1.0) + dither + 0.5) / (levels - 1.0);\n sdr = clamp(sdr, vec3f(0.0), vec3f(1.0));\n }\n\n return vec4<f32>(sdr, 1.0);\n}";
11034
- class PostProcessPass extends BasePass {
11333
+ var volumetric_raymarch_default = "const MAX_LIGHTS: u32 = 768u;\nconst MAX_SPOT_SHADOWS: i32 = 16;\nconst SPOT_ATLAS_SIZE: f32 = 2048.0;\nconst SPOT_TILE_SIZE: f32 = 512.0;\nconst SPOT_TILES_PER_ROW: i32 = 4;\n\nstruct Uniforms {\n inverseProjection: mat4x4f,\n inverseView: mat4x4f,\n cameraPosition: vec3f,\n nearPlane: f32,\n farPlane: f32,\n maxSamples: f32,\n time: f32,\n fogDensity: f32,\n fogColor: vec3f,\n shadowsEnabled: f32,\n mainLightDir: vec3f,\n mainLightIntensity: f32,\n mainLightColor: vec3f,\n scatterStrength: f32,\n fogHeightFade: vec2f,\n maxDistance: f32,\n lightCount: f32,\n debugMode: f32,\n noiseStrength: f32, \n noiseAnimated: f32, \n mainLightScatter: f32, \n noiseScale: f32, \n mainLightSaturation: f32, \n}\n\nstruct Light {\n enabled: u32, \n _pad0: u32, \n _pad1: u32, \n _pad2: u32, \n position: vec3f, \n _pad3: f32, \n color: vec4f, \n direction: vec3f, \n _pad4: f32, \n geom: vec4f, \n shadowIndex: i32, \n _pad5: u32, \n _pad6: u32, \n _pad7: u32, \n}\n\nstruct VertexOutput {\n @builtin(position) position: vec4f,\n @location(0) uv: vec2f,\n}\n\n@group(0) @binding(0) var<uniform> uniforms: Uniforms;\n@group(0) @binding(1) var depthTexture: texture_depth_2d;\n@group(0) @binding(2) var cascadeShadowMaps: texture_depth_2d_array;\n@group(0) @binding(3) var shadowSampler: sampler_comparison;\n@group(0) @binding(4) var<storage, read> cascadeMatrices: array<mat4x4f>;\n@group(0) @binding(5) var<storage, read> lights: array<Light, MAX_LIGHTS>;\n@group(0) @binding(6) var spotShadowAtlas: texture_depth_2d;\n@group(0) @binding(7) var<storage, read> spotMatrices: array<mat4x4f, MAX_SPOT_SHADOWS>;\n\n@vertex\nfn vertexMain(@builtin(vertex_index) vertexIndex: u32) -> VertexOutput {\n var output: VertexOutput;\n let x = f32(vertexIndex & 1u) * 4.0 - 1.0;\n let y = f32(vertexIndex >> 1u) * 4.0 - 1.0;\n output.position = vec4f(x, y, 0.0, 1.0);\n output.uv = vec2f((x + 1.0) * 0.5, (1.0 - y) * 0.5);\n return output;\n}\n\nfn hash(p: vec3f) -> f32 {\n var p3 = fract(p * 0.1031);\n p3 += dot(p3, p3.yzx + 33.33);\n return fract((p3.x + p3.y) * p3.z);\n}\n\nfn noise3d(p: vec3f) -> f32 {\n let i = floor(p);\n let f = fract(p);\n let u = f * f * (3.0 - 2.0 * f);\n\n return mix(\n mix(mix(hash(i + vec3f(0,0,0)), hash(i + vec3f(1,0,0)), u.x),\n mix(hash(i + vec3f(0,1,0)), hash(i + vec3f(1,1,0)), u.x), u.y),\n mix(mix(hash(i + vec3f(0,0,1)), hash(i + vec3f(1,0,1)), u.x),\n mix(hash(i + vec3f(0,1,1)), hash(i + vec3f(1,1,1)), u.x), u.y),\n u.z\n );\n}\n\nfn fbm(p: vec3f) -> f32 {\n var value = 0.0;\n var amplitude = 0.5;\n var pos = p;\n for (var i = 0; i < 3; i++) {\n value += amplitude * noise3d(pos);\n pos *= 2.0;\n amplitude *= 0.5;\n }\n return value;\n}\n\nfn getFogDensity(worldPos: vec3f, dist: f32) -> f32 {\n \n let heightFade = uniforms.fogHeightFade;\n var heightMod = 1.0;\n if (heightFade.y > heightFade.x) {\n let range = heightFade.y - heightFade.x;\n let fadeZone = range * 0.1; \n let topFade = 1.0 - smoothstep(heightFade.y - fadeZone, heightFade.y, worldPos.y);\n let bottomFade = smoothstep(heightFade.x, heightFade.x + fadeZone, worldPos.y);\n heightMod = topFade * bottomFade;\n }\n\n \n if (uniforms.noiseStrength < 0.001) {\n return uniforms.fogDensity * heightMod;\n }\n\n \n let timeOffset = select(0.0, uniforms.time, uniforms.noiseAnimated > 0.5);\n\n \n \n let scale1 = uniforms.noiseScale; \n let scale2 = uniforms.noiseScale * 0.32; \n\n let noisePos = worldPos * scale1 + vec3f(timeOffset * 0.15, timeOffset * 0.02, timeOffset * 0.08);\n let noiseVal = fbm(noisePos);\n\n \n let noisePos2 = worldPos * scale2 + vec3f(timeOffset * 0.05, 0.0, timeOffset * 0.03);\n let noiseVal2 = fbm(noisePos2);\n\n \n let combinedNoise = noiseVal * 0.6 + noiseVal2 * 0.4;\n\n \n \n \n let noiseRange = combinedNoise * 1.0 + 0.2; \n let noiseMapped = mix(1.0, noiseRange, uniforms.noiseStrength);\n\n return uniforms.fogDensity * heightMod * noiseMapped;\n}\n\nfn sampleShadow(worldPos: vec3f) -> f32 {\n if (uniforms.shadowsEnabled < 0.5) {\n return 1.0;\n }\n\n \n for (var cascade = 0; cascade < 3; cascade++) {\n let lightSpacePos = cascadeMatrices[cascade] * vec4f(worldPos, 1.0);\n let projCoords = lightSpacePos.xyz / lightSpacePos.w;\n\n \n let uv = vec2f(projCoords.x * 0.5 + 0.5, 0.5 - projCoords.y * 0.5);\n\n \n if (uv.x >= 0.0 && uv.x <= 1.0 && uv.y >= 0.0 && uv.y <= 1.0 &&\n projCoords.z >= 0.0 && projCoords.z <= 1.0) {\n\n let bias = 0.003 * f32(cascade + 1);\n let depth = projCoords.z - bias;\n let clampedUV = clamp(uv, vec2f(0.002), vec2f(0.998));\n let clampedDepth = clamp(depth, 0.001, 0.999);\n\n return textureSampleCompareLevel(cascadeShadowMaps, shadowSampler, clampedUV, cascade, clampedDepth);\n }\n }\n\n return 1.0;\n}\n\nfn sampleSpotShadow(worldPos: vec3f, slotIndex: i32) -> f32 {\n if (slotIndex < 0 || slotIndex >= MAX_SPOT_SHADOWS) {\n return 1.0;\n }\n\n \n let lightMatrix = spotMatrices[slotIndex];\n\n \n let lightSpacePos = lightMatrix * vec4f(worldPos, 1.0);\n\n \n let w = max(abs(lightSpacePos.w), 0.0001) * sign(lightSpacePos.w + 0.0001);\n let projCoords = lightSpacePos.xyz / w;\n\n \n if (abs(projCoords.x) > 1.0 || abs(projCoords.y) > 1.0 ||\n projCoords.z < 0.0 || projCoords.z > 1.0) {\n return 1.0;\n }\n\n \n let col = slotIndex % SPOT_TILES_PER_ROW;\n let row = slotIndex / SPOT_TILES_PER_ROW;\n\n \n let tileUV = vec2f(projCoords.x * 0.5 + 0.5, 0.5 - projCoords.y * 0.5);\n\n \n let tileOffsetX = f32(col) * SPOT_TILE_SIZE;\n let tileOffsetY = f32(row) * SPOT_TILE_SIZE;\n let atlasUV = vec2f(\n (tileOffsetX + tileUV.x * SPOT_TILE_SIZE) / SPOT_ATLAS_SIZE,\n (tileOffsetY + tileUV.y * SPOT_TILE_SIZE) / SPOT_ATLAS_SIZE\n );\n\n \n let bias = 0.005;\n let depth = clamp(projCoords.z - bias, 0.001, 0.999);\n\n return textureSampleCompareLevel(spotShadowAtlas, shadowSampler, atlasUV, depth);\n}\n\nfn calculateLightContribution(worldPos: vec3f, light: Light, worldRayDir: vec3f) -> vec3f {\n let toLight = light.position - worldPos;\n let dist = length(toLight);\n let radius = light.geom.x;\n\n \n if (dist > radius) {\n return vec3f(0.0);\n }\n\n \n \n let normalizedDist = dist / radius;\n\n \n \n let attenuation = 1.0 - smoothstep(0.0, 1.0, normalizedDist);\n\n \n let innerCone = light.geom.y;\n let outerCone = light.geom.z;\n var spotAttenuation = 1.0;\n let isSpotlight = outerCone > 0.0;\n\n if (isSpotlight) {\n let lightDir = normalize(-toLight);\n let spotCos = dot(lightDir, normalize(light.direction));\n spotAttenuation = smoothstep(outerCone, innerCone, spotCos);\n if (spotAttenuation <= 0.0) {\n return vec3f(0.0);\n }\n }\n\n \n var shadow = 1.0;\n if (isSpotlight && light.shadowIndex >= 0 && uniforms.shadowsEnabled > 0.5) {\n shadow = sampleSpotShadow(worldPos, light.shadowIndex);\n }\n\n \n let phase = 0.25; \n\n \n let intensity = light.color.a;\n return light.color.rgb * intensity * attenuation * spotAttenuation * shadow * phase;\n}\n\nfn phaseHG(cosTheta: f32, g: f32) -> f32 {\n let g2 = g * g;\n let denom = 1.0 + g2 - 2.0 * g * cosTheta;\n return (1.0 - g2) / (4.0 * 3.14159 * pow(denom, 1.5));\n}\n\nfn linearizeDepth(depth: f32) -> f32 {\n let near = uniforms.nearPlane;\n let far = uniforms.farPlane;\n return near + depth * (far - near);\n}\n\n@fragment\nfn fragmentMain(input: VertexOutput) -> @location(0) vec4f {\n let uv = input.uv;\n\n \n let DEBUG = i32(uniforms.debugMode);\n\n \n \n var clipUV = uv;\n clipUV.y = 1.0 - clipUV.y; \n let ndc = vec4f(clipUV * 2.0 - 1.0, 0.0, 1.0);\n\n \n let viewRay4 = uniforms.inverseProjection * ndc;\n let viewDir = normalize(viewRay4.xyz / viewRay4.w);\n\n \n let worldRayDir = normalize((uniforms.inverseView * vec4f(viewDir, 0.0)).xyz);\n\n \n let depthTexSize = vec2f(textureDimensions(depthTexture));\n let depthCoord = vec2i(uv * depthTexSize);\n let rawDepth = textureLoad(depthTexture, depthCoord, 0);\n\n \n if (DEBUG == 1) {\n return vec4f(rawDepth, rawDepth, rawDepth, 1.0);\n }\n\n \n if (DEBUG == 2) {\n return vec4f(worldRayDir * 0.5 + 0.5, 1.0);\n }\n\n \n if (DEBUG == 4) {\n let vzVis = abs(viewDir.z);\n return vec4f(vzVis, vzVis, vzVis, 1.0);\n }\n\n \n \n \n \n if (DEBUG == 7) {\n let numLights = i32(uniforms.lightCount);\n if (numLights > 0) {\n let light = lights[0];\n \n let camToLightDist = length(light.position - uniforms.cameraPosition);\n let radius = light.geom.x;\n \n return vec4f(\n clamp(camToLightDist / 50.0, 0.0, 1.0),\n f32(light.enabled) * 0.5,\n clamp(radius / 50.0, 0.0, 1.0),\n 1.0\n );\n }\n return vec4f(1.0, 0.0, 0.0, 1.0); \n }\n\n \n \n if (DEBUG == 8) {\n let numLights = i32(uniforms.lightCount);\n if (numLights > 0) {\n let light = lights[0];\n \n return vec4f(\n (light.position.x + 100.0) / 200.0,\n (light.position.y + 10.0) / 60.0,\n (light.position.z + 100.0) / 200.0,\n 1.0\n );\n }\n return vec4f(0.0, 0.0, 0.0, 1.0);\n }\n\n \n var geometryDist = 1000000.0;\n if (rawDepth < 0.9999) {\n let linearDepth = linearizeDepth(rawDepth);\n geometryDist = linearDepth / max(0.001, -viewDir.z) * 0.98;\n }\n\n \n \n let fogBottom = uniforms.fogHeightFade.x;\n let fogTop = uniforms.fogHeightFade.y;\n let camY = uniforms.cameraPosition.y;\n\n \n \n \n \n var tStart = 0.0;\n var tEnd = geometryDist;\n\n if (abs(worldRayDir.y) > 0.0001) {\n let tBottom = (fogBottom - camY) / worldRayDir.y;\n let tTop = (fogTop - camY) / worldRayDir.y;\n\n \n let tEnter = min(tBottom, tTop);\n let tExit = max(tBottom, tTop);\n\n \n if (camY < fogBottom || camY > fogTop) {\n tStart = max(0.0, tEnter);\n }\n\n \n tEnd = min(tEnd, max(0.0, tExit));\n } else {\n \n if (camY < fogBottom || camY > fogTop) {\n return vec4f(0.0); \n }\n }\n\n \n if (tStart >= tEnd || (tEnd - tStart) < 0.5) {\n return vec4f(0.0);\n }\n\n \n \n if (DEBUG == 9) {\n let rayLength = tEnd - tStart;\n return vec4f(tStart / 50.0, tEnd / 50.0, rayLength / 50.0, 1.0);\n }\n\n \n \n if (DEBUG == 10) {\n let numLights = i32(uniforms.lightCount);\n if (numLights > 0) {\n let light = lights[0];\n let radius = light.geom.x;\n var minDist = 9999.0;\n var hitCount = 0.0;\n let rayLength = tEnd - tStart;\n for (var ti = 0; ti < 20; ti++) {\n let testT = tStart + rayLength * f32(ti) / 20.0;\n let testPos = uniforms.cameraPosition + worldRayDir * testT;\n let dist = length(light.position - testPos);\n minDist = min(minDist, dist);\n if (dist < radius) {\n hitCount += 1.0;\n }\n }\n \n return vec4f(minDist / 50.0, hitCount / 20.0, radius / 50.0, 1.0);\n }\n return vec4f(1.0, 0.0, 0.0, 1.0);\n }\n\n \n if (DEBUG == 3) {\n let samplePos = uniforms.cameraPosition + worldRayDir * min(10.0, tEnd);\n let n = getFogDensity(samplePos, 10.0);\n return vec4f(n, n, n, 1.0);\n }\n\n \n if (DEBUG == 5) {\n let worldPos = uniforms.cameraPosition + worldRayDir * min(5.0, tEnd);\n return vec4f(fract(worldPos * 0.1), 1.0);\n }\n\n \n \n let numSamples = i32(uniforms.maxSamples);\n let fNumSamples = f32(numSamples);\n\n \n let jitter = fract(sin(dot(input.position.xy, vec2f(12.9898, 78.233))) * 43758.5453);\n\n var accumulatedColor = vec3f(0.0);\n var accumulatedMainLight = vec3f(0.0); \n var accumulatedShadow = 0.0; \n var shadowWeight = 0.0; \n var accumulatedAlpha = 0.0;\n\n \n let lightDir = normalize(uniforms.mainLightDir);\n let viewToLight = dot(worldRayDir, lightDir);\n \n let hgPhase = phaseHG(viewToLight, 0.6);\n let phase = max(0.4, hgPhase); \n\n \n\n let rayLength = tEnd - tStart;\n let minStepSize = 0.25; \n let maxStepSize = 2.0; \n let stepSize = clamp(rayLength / fNumSamples, minStepSize, maxStepSize);\n \n\n \n var t = tStart + jitter * stepSize;\n\n \n var debugMaxLight = vec3f(0.0);\n var debugLightHits = 0.0;\n\n for (var i = 0; i < numSamples; i++) {\n \n if (t > tEnd) { break; }\n\n let samplePos = uniforms.cameraPosition + worldRayDir * t;\n\n \n t += stepSize;\n\n \n let density = getFogDensity(samplePos, t);\n \n \n if (density <= 0.0) { continue; }\n\n \n \n var mainLighting = vec3f(0.0);\n var pointLighting = vec3f(0.0);\n\n \n if (uniforms.mainLightIntensity > 0.0) {\n let rawShadow = sampleShadow(samplePos);\n\n \n let weight = density * stepSize;\n accumulatedShadow += rawShadow * weight;\n shadowWeight += weight;\n\n \n mainLighting = uniforms.mainLightColor * uniforms.mainLightIntensity * phase * uniforms.mainLightScatter;\n }\n\n \n let numLights = i32(uniforms.lightCount);\n for (var li = 0; li < numLights; li++) {\n let light = lights[li];\n if (light.enabled == 0u) { continue; }\n\n let contrib = calculateLightContribution(samplePos, light, worldRayDir);\n pointLighting += contrib;\n\n \n if (length(contrib) > 0.001) {\n debugLightHits += 1.0;\n debugMaxLight = max(debugMaxLight, contrib);\n }\n }\n\n \n let pointColor = pointLighting * uniforms.scatterStrength * uniforms.fogColor;\n let mainColor = mainLighting * uniforms.fogColor;\n\n \n \n \n let rawSampleAlpha = density * stepSize;\n let sampleAlpha = min(rawSampleAlpha, 0.03); \n\n \n let colorWeight = rawSampleAlpha * (1.0 - accumulatedAlpha);\n accumulatedColor += pointColor * colorWeight;\n accumulatedMainLight += mainColor * colorWeight;\n accumulatedAlpha += sampleAlpha * (1.0 - accumulatedAlpha);\n }\n\n \n accumulatedAlpha = min(accumulatedAlpha, 1.0);\n\n \n let avgShadow = select(1.0, accumulatedShadow / shadowWeight, shadowWeight > 0.001);\n\n \n \n \n let contrastShadow = smoothstep(0.25, 0.75, avgShadow);\n \n let finalShadow = mix(avgShadow, contrastShadow, 0.88);\n\n \n let sat = uniforms.mainLightSaturation;\n let saturatedShadow = sat * (1.0 - exp(-finalShadow * 3.0 / max(sat, 0.01)));\n\n \n accumulatedColor += accumulatedMainLight * saturatedShadow;\n\n \n \n if (DEBUG == 11) {\n return vec4f(length(debugMaxLight), debugLightHits / 100.0, 0.0, 1.0);\n }\n\n \n \n if (DEBUG == 12) {\n let distMarched = t - tStart;\n let iterCount = distMarched / stepSize;\n return vec4f(distMarched / 50.0, iterCount / fNumSamples, accumulatedAlpha, 1.0);\n }\n\n \n \n if (DEBUG == 13) {\n let numLights = i32(uniforms.lightCount);\n if (numLights > 0) {\n let light = lights[0];\n let radius = light.geom.x;\n var minDistToLight = 9999.0;\n var closestT = 0.0;\n let testRayLen = t - tStart; \n for (var ti = 0; ti < 32; ti++) {\n let testT = tStart + testRayLen * f32(ti) / 32.0;\n let testPos = uniforms.cameraPosition + worldRayDir * testT;\n let distToLight = length(light.position - testPos);\n if (distToLight < minDistToLight) {\n minDistToLight = distToLight;\n closestT = testT;\n }\n }\n \n \n \n return vec4f(minDistToLight / radius, closestT / 50.0, radius / 50.0, 1.0);\n }\n return vec4f(1.0, 0.0, 0.0, 1.0);\n }\n\n \n if (DEBUG == 6) {\n return vec4f(accumulatedColor, 1.0);\n }\n\n return vec4f(accumulatedColor, accumulatedAlpha);\n}";
11334
+ var volumetric_blur_default = "struct Uniforms {\n direction: vec2f,\n texelSize: vec2f,\n radius: f32,\n _pad0: f32,\n _pad1: f32,\n _pad2: f32,\n}\n\nstruct VertexOutput {\n @builtin(position) position: vec4f,\n @location(0) uv: vec2f,\n}\n\n@group(0) @binding(0) var<uniform> uniforms: Uniforms;\n@group(0) @binding(1) var inputTexture: texture_2d<f32>;\n@group(0) @binding(2) var inputSampler: sampler;\n\n@vertex\nfn vertexMain(@builtin(vertex_index) vertexIndex: u32) -> VertexOutput {\n var output: VertexOutput;\n\n let x = f32(vertexIndex & 1u) * 4.0 - 1.0;\n let y = f32(vertexIndex >> 1u) * 4.0 - 1.0;\n\n output.position = vec4f(x, y, 0.0, 1.0);\n output.uv = vec2f((x + 1.0) * 0.5, (1.0 - y) * 0.5);\n\n return output;\n}\n\nfn gaussian(x: f32, sigma: f32) -> f32 {\n return exp(-(x * x) / (2.0 * sigma * sigma));\n}\n\n@fragment\nfn fragmentMain(input: VertexOutput) -> @location(0) vec4f {\n let uv = input.uv;\n let radius = uniforms.radius;\n let sigma = radius / 3.0; \n\n \n var color = textureSample(inputTexture, inputSampler, uv);\n var totalWeight = 1.0;\n\n \n let stepSize = 1.5;\n let numSamples = i32(ceil(radius / stepSize));\n\n \n let dir = uniforms.direction * uniforms.texelSize;\n\n \n for (var i = 1; i <= numSamples; i++) {\n let offset = f32(i) * stepSize;\n let weight = gaussian(offset, sigma);\n\n \n let uvPos = uv + dir * offset;\n let samplePos = textureSample(inputTexture, inputSampler, uvPos);\n color += samplePos * weight;\n\n \n let uvNeg = uv - dir * offset;\n let sampleNeg = textureSample(inputTexture, inputSampler, uvNeg);\n color += sampleNeg * weight;\n\n totalWeight += weight * 2.0;\n }\n\n \n color /= totalWeight;\n\n return color;\n}";
11335
+ var volumetric_composite_default = "struct Uniforms {\n canvasSize: vec2f,\n renderSize: vec2f,\n texelSize: vec2f,\n \n brightnessThreshold: f32, \n minVisibility: f32, \n skyBrightness: f32, \n _pad: f32,\n}\n\nstruct VertexOutput {\n @builtin(position) position: vec4f,\n @location(0) uv: vec2f,\n}\n\n@group(0) @binding(0) var<uniform> uniforms: Uniforms;\n@group(0) @binding(1) var sceneTexture: texture_2d<f32>;\n@group(0) @binding(2) var fogTexture: texture_2d<f32>;\n@group(0) @binding(3) var linearSampler: sampler;\n@group(0) @binding(4) var depthTexture: texture_depth_2d;\n\n@vertex\nfn vertexMain(@builtin(vertex_index) vertexIndex: u32) -> VertexOutput {\n var output: VertexOutput;\n\n let x = f32(vertexIndex & 1u) * 4.0 - 1.0;\n let y = f32(vertexIndex >> 1u) * 4.0 - 1.0;\n\n output.position = vec4f(x, y, 0.0, 1.0);\n output.uv = vec2f((x + 1.0) * 0.5, (1.0 - y) * 0.5);\n\n return output;\n}\n\n@fragment\nfn fragmentMain(input: VertexOutput) -> @location(0) vec4f {\n let uv = input.uv;\n\n \n let sceneColor = textureSample(sceneTexture, linearSampler, uv).rgb;\n\n \n let fog = textureSample(fogTexture, linearSampler, uv);\n\n \n let depthCoord = vec2i(input.position.xy);\n let depth = textureLoad(depthTexture, depthCoord, 0);\n\n \n var luminance = dot(sceneColor, vec3f(0.299, 0.587, 0.114));\n\n \n \n let isSky = depth > 0.9999;\n if (isSky) {\n luminance = max(luminance, uniforms.skyBrightness);\n }\n\n \n \n let threshold = uniforms.brightnessThreshold;\n let minVis = uniforms.minVisibility;\n\n \n \n \n \n let falloff = 1.0 / (1.0 + luminance / max(threshold, 0.01));\n let fogVisibility = mix(minVis, 1.0, falloff);\n\n \n let finalColor = sceneColor + fog.rgb * fogVisibility;\n\n return vec4f(finalColor, 1.0);\n}";
11336
+ class VolumetricFogPass extends BasePass {
11035
11337
  constructor(engine = null) {
11036
- super("PostProcess", engine);
11037
- this.pipeline = null;
11338
+ super("VolumetricFog", engine);
11339
+ this.raymarchPipeline = null;
11340
+ this.blurHPipeline = null;
11341
+ this.blurVPipeline = null;
11342
+ this.compositePipeline = null;
11343
+ this.raymarchBGL = null;
11344
+ this.blurBGL = null;
11345
+ this.compositeBGL = null;
11346
+ this.raymarchTexture = null;
11347
+ this.blurTempTexture = null;
11348
+ this.blurredTexture = null;
11349
+ this.outputTexture = null;
11350
+ this.raymarchUniformBuffer = null;
11351
+ this.blurHUniformBuffer = null;
11352
+ this.blurVUniformBuffer = null;
11353
+ this.compositeUniformBuffer = null;
11354
+ this.linearSampler = null;
11038
11355
  this.inputTexture = null;
11039
- this.bloomTexture = null;
11040
- this.dummyBloomTexture = null;
11041
- this.noiseTexture = null;
11042
- this.noiseSize = 64;
11043
- this.noiseAnimated = true;
11044
- this.guiCanvas = null;
11045
- this.guiTexture = null;
11046
- this.guiSampler = null;
11356
+ this.gbuffer = null;
11357
+ this.shadowPass = null;
11358
+ this.lightingPass = null;
11359
+ this.canvasWidth = 0;
11360
+ this.canvasHeight = 0;
11361
+ this.renderWidth = 0;
11362
+ this.renderHeight = 0;
11363
+ this._currentMainLightScatter = null;
11364
+ this._lastUpdateTime = 0;
11365
+ this._cameraInShadowSmooth = 0;
11366
+ this._skyVisible = true;
11367
+ this._skyCheckPending = false;
11368
+ this._lastSkyCheckTime = 0;
11047
11369
  }
11048
- // Convenience getter for exposure setting
11049
- get exposure() {
11050
- return this.settings?.environment?.exposure ?? 1.6;
11370
+ // Settings getters
11371
+ get volumetricSettings() {
11372
+ return this.settings?.volumetricFog ?? {};
11051
11373
  }
11052
- // Convenience getter for fxaa setting
11053
- get fxaa() {
11054
- return this.settings?.rendering?.fxaa ?? true;
11374
+ get fogSettings() {
11375
+ return this.settings?.fog ?? {};
11055
11376
  }
11056
- // Convenience getter for dithering settings
11057
- get ditheringEnabled() {
11058
- return this.settings?.dithering?.enabled ?? true;
11377
+ get isVolumetricEnabled() {
11378
+ return this.volumetricSettings.enabled ?? false;
11059
11379
  }
11060
- get colorLevels() {
11061
- return this.settings?.dithering?.colorLevels ?? 32;
11380
+ get resolution() {
11381
+ return this.volumetricSettings.resolution ?? 0.25;
11062
11382
  }
11063
- // Convenience getters for bloom settings
11064
- get bloomEnabled() {
11065
- return this.settings?.bloom?.enabled ?? true;
11383
+ get maxSamples() {
11384
+ return this.volumetricSettings.maxSamples ?? 32;
11066
11385
  }
11067
- get bloomIntensity() {
11068
- return this.settings?.bloom?.intensity ?? 1;
11386
+ get blurRadius() {
11387
+ return this.volumetricSettings.blurRadius ?? 4;
11069
11388
  }
11070
- get bloomRadius() {
11071
- return this.settings?.bloom?.radius ?? 5;
11389
+ get fogDensity() {
11390
+ return this.volumetricSettings.density ?? this.volumetricSettings.densityMultiplier ?? 0.5;
11391
+ }
11392
+ get scatterStrength() {
11393
+ return this.volumetricSettings.scatterStrength ?? 1;
11394
+ }
11395
+ get maxDistance() {
11396
+ return this.volumetricSettings.maxDistance ?? 20;
11397
+ }
11398
+ get heightRange() {
11399
+ return this.volumetricSettings.heightRange ?? [-5, 20];
11400
+ }
11401
+ get shadowsEnabled() {
11402
+ return this.volumetricSettings.shadowsEnabled ?? true;
11403
+ }
11404
+ get noiseStrength() {
11405
+ return this.volumetricSettings.noiseStrength ?? 1;
11406
+ }
11407
+ // 0 = uniform fog, 1 = full noise
11408
+ get noiseAnimated() {
11409
+ return this.volumetricSettings.noiseAnimated ?? true;
11410
+ }
11411
+ get noiseScale() {
11412
+ return this.volumetricSettings.noiseScale ?? 0.25;
11413
+ }
11414
+ // Noise frequency (higher = finer detail)
11415
+ get mainLightScatter() {
11416
+ return this.volumetricSettings.mainLightScatter ?? 1;
11417
+ }
11418
+ // Scatter when camera in light
11419
+ get mainLightScatterDark() {
11420
+ return this.volumetricSettings.mainLightScatterDark ?? 3;
11421
+ }
11422
+ // Scatter when camera in shadow
11423
+ get mainLightSaturation() {
11424
+ return this.volumetricSettings.mainLightSaturation ?? 1;
11425
+ }
11426
+ // Max brightness cap
11427
+ // Brightness-based attenuation (fog less visible over bright surfaces)
11428
+ get brightnessThreshold() {
11429
+ return this.volumetricSettings.brightnessThreshold ?? 1;
11430
+ }
11431
+ // Scene luminance where fog starts fading
11432
+ get minVisibility() {
11433
+ return this.volumetricSettings.minVisibility ?? 0.15;
11434
+ }
11435
+ // Minimum fog visibility over very bright surfaces
11436
+ get skyBrightness() {
11437
+ return this.volumetricSettings.skyBrightness ?? 5;
11438
+ }
11439
+ // Virtual brightness for sky (far depth)
11440
+ // Debug mode: 0=normal, 1=depth, 2=ray dir, 3=noise, 4=viewDir.z, 5=worldPos, 6=accum, 7=light dist, 8=light pos
11441
+ get debugMode() {
11442
+ return this.volumetricSettings.debug ?? 0;
11072
11443
  }
11073
- /**
11074
- * Set the input texture (HDR image from LightingPass)
11075
- * @param {Texture} texture - Input HDR texture
11076
- */
11077
11444
  setInputTexture(texture) {
11078
- if (this.inputTexture !== texture) {
11079
- this.inputTexture = texture;
11080
- this._needsRebuild = true;
11081
- }
11445
+ this.inputTexture = texture;
11082
11446
  }
11083
- /**
11084
- * Set the bloom texture (from BloomPass)
11085
- * @param {Object} bloomTexture - Bloom texture with mip levels
11086
- */
11087
- setBloomTexture(bloomTexture) {
11088
- if (this.bloomTexture !== bloomTexture) {
11089
- this.bloomTexture = bloomTexture;
11090
- this._needsRebuild = true;
11091
- }
11447
+ setGBuffer(gbuffer) {
11448
+ this.gbuffer = gbuffer;
11092
11449
  }
11093
- /**
11094
- * Set the noise texture for dithering
11095
- * @param {Texture} texture - Noise texture (blue noise or bayer dither)
11096
- * @param {number} size - Texture size
11097
- * @param {boolean} animated - Whether to animate noise offset each frame
11098
- */
11099
- setNoise(texture, size = 64, animated = true) {
11100
- this.noiseTexture = texture;
11101
- this.noiseSize = size;
11102
- this.noiseAnimated = animated;
11103
- this._needsRebuild = true;
11450
+ setShadowPass(shadowPass) {
11451
+ this.shadowPass = shadowPass;
11104
11452
  }
11105
- /**
11106
- * Set the GUI canvas for overlay rendering
11107
- * @param {HTMLCanvasElement} canvas - 2D canvas with GUI content
11108
- */
11109
- setGuiCanvas(canvas) {
11110
- this.guiCanvas = canvas;
11453
+ setLightingPass(lightingPass) {
11454
+ this.lightingPass = lightingPass;
11455
+ }
11456
+ getOutputTexture() {
11457
+ return this.outputTexture;
11458
+ }
11459
+ // Unused setters (kept for API compatibility)
11460
+ setHiZPass() {
11111
11461
  }
11112
11462
  async _init() {
11113
11463
  const { device } = this.engine;
11114
- const dummyTexture = device.createTexture({
11115
- label: "Dummy Bloom Texture",
11116
- size: [1, 1, 1],
11117
- format: "rgba16float",
11118
- mipLevelCount: 1,
11119
- usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST
11120
- });
11121
- device.queue.writeTexture(
11122
- { texture: dummyTexture },
11123
- new Float32Array([0, 0, 0, 0]).buffer,
11124
- { bytesPerRow: 8 },
11125
- { width: 1, height: 1 }
11126
- );
11127
- const dummySampler = device.createSampler({
11128
- label: "Dummy Bloom Sampler",
11464
+ this.linearSampler = device.createSampler({
11465
+ label: "Volumetric Linear Sampler",
11129
11466
  minFilter: "linear",
11130
- magFilter: "linear"
11467
+ magFilter: "linear",
11468
+ addressModeU: "clamp-to-edge",
11469
+ addressModeV: "clamp-to-edge"
11131
11470
  });
11132
- this.dummyBloomTexture = {
11133
- texture: dummyTexture,
11134
- view: dummyTexture.createView(),
11135
- sampler: dummySampler,
11136
- mipCount: 1
11137
- };
11138
- this.guiSampler = device.createSampler({
11139
- label: "GUI Sampler",
11140
- minFilter: "linear",
11141
- magFilter: "linear"
11471
+ this.fallbackShadowSampler = device.createSampler({
11472
+ label: "Volumetric Fallback Shadow Sampler",
11473
+ compare: "less"
11142
11474
  });
11143
- const dummyGuiTexture = device.createTexture({
11144
- label: "Dummy GUI Texture",
11475
+ this.fallbackCascadeShadowMap = device.createTexture({
11476
+ label: "Volumetric Fallback Cascade Shadow",
11477
+ size: [1, 1, 3],
11478
+ format: "depth32float",
11479
+ usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.RENDER_ATTACHMENT
11480
+ });
11481
+ const identityMatrix = new Float32Array([1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]);
11482
+ const matrixData = new Float32Array(16 * 3);
11483
+ for (let i = 0; i < 3; i++) matrixData.set(identityMatrix, i * 16);
11484
+ this.fallbackCascadeMatrices = device.createBuffer({
11485
+ label: "Volumetric Fallback Cascade Matrices",
11486
+ size: 16 * 4 * 3,
11487
+ usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST
11488
+ });
11489
+ device.queue.writeBuffer(this.fallbackCascadeMatrices, 0, matrixData);
11490
+ this.fallbackLightsBuffer = device.createBuffer({
11491
+ label: "Volumetric Fallback Lights",
11492
+ size: 768 * 96,
11493
+ // MAX_LIGHTS * 96 bytes per light
11494
+ usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST
11495
+ });
11496
+ this.fallbackSpotShadowAtlas = device.createTexture({
11497
+ label: "Volumetric Fallback Spot Shadow Atlas",
11145
11498
  size: [1, 1, 1],
11146
- format: "rgba8unorm",
11147
- usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST
11499
+ format: "depth32float",
11500
+ usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.RENDER_ATTACHMENT
11148
11501
  });
11149
- device.queue.writeTexture(
11150
- { texture: dummyGuiTexture },
11151
- new Uint8Array([0, 0, 0, 0]),
11152
- { bytesPerRow: 4 },
11153
- { width: 1, height: 1 }
11154
- );
11155
- this.dummyGuiTexture = {
11156
- texture: dummyGuiTexture,
11157
- view: dummyGuiTexture.createView(),
11158
- sampler: this.guiSampler
11159
- };
11502
+ const spotMatrixData = new Float32Array(16 * 16);
11503
+ for (let i = 0; i < 16; i++) spotMatrixData.set(identityMatrix, i * 16);
11504
+ this.fallbackSpotMatrices = device.createBuffer({
11505
+ label: "Volumetric Fallback Spot Matrices",
11506
+ size: 16 * 4 * 16,
11507
+ usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST
11508
+ });
11509
+ device.queue.writeBuffer(this.fallbackSpotMatrices, 0, spotMatrixData);
11510
+ await this._createResources(this.engine.canvas.width, this.engine.canvas.height);
11160
11511
  }
11161
- /**
11162
- * Build or rebuild the pipeline
11163
- */
11164
- async _buildPipeline() {
11165
- if (!this.inputTexture) {
11166
- return;
11167
- }
11168
- const textures = [this.inputTexture];
11169
- if (this.noiseTexture) {
11170
- textures.push(this.noiseTexture);
11171
- }
11172
- const effectiveBloomTexture = this.bloomTexture || this.dummyBloomTexture;
11173
- textures.push(effectiveBloomTexture);
11174
- const effectiveGuiTexture = this.guiTexture || this.dummyGuiTexture;
11175
- textures.push(effectiveGuiTexture);
11176
- const hasBloom = this.bloomTexture && this.bloomEnabled;
11512
+ async _createResources(width, height) {
11513
+ const { device } = this.engine;
11514
+ this.canvasWidth = width;
11515
+ this.canvasHeight = height;
11516
+ this.renderWidth = Math.max(1, Math.floor(width * this.resolution));
11517
+ this.renderHeight = Math.max(1, Math.floor(height * this.resolution));
11518
+ this._destroyTextures();
11519
+ this.raymarchTexture = this._create2DTexture("Raymarch Output", this.renderWidth, this.renderHeight);
11520
+ this.blurTempTexture = this._create2DTexture("Blur Temp", this.renderWidth, this.renderHeight);
11521
+ this.blurredTexture = this._create2DTexture("Blurred Fog", this.renderWidth, this.renderHeight);
11522
+ this.outputTexture = await Texture.renderTarget(this.engine, "rgba16float", width, height);
11523
+ this.raymarchUniformBuffer = this._createUniformBuffer("Raymarch Uniforms", 256);
11524
+ this.blurHUniformBuffer = this._createUniformBuffer("Blur H Uniforms", 32);
11525
+ this.blurVUniformBuffer = this._createUniformBuffer("Blur V Uniforms", 32);
11526
+ this.compositeUniformBuffer = this._createUniformBuffer("Composite Uniforms", 48);
11527
+ await this._createPipelines();
11528
+ }
11529
+ _create2DTexture(label, width, height) {
11530
+ const texture = this.engine.device.createTexture({
11531
+ label,
11532
+ size: [width, height, 1],
11533
+ format: "rgba16float",
11534
+ usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING
11535
+ });
11536
+ return { texture, view: texture.createView(), width, height };
11537
+ }
11538
+ _createUniformBuffer(label, size) {
11539
+ return this.engine.device.createBuffer({
11540
+ label,
11541
+ size,
11542
+ usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST
11543
+ });
11544
+ }
11545
+ async _createPipelines() {
11546
+ const { device } = this.engine;
11547
+ const raymarchModule = device.createShaderModule({
11548
+ label: "Volumetric Raymarch Shader",
11549
+ code: volumetric_raymarch_default
11550
+ });
11551
+ this.raymarchBGL = device.createBindGroupLayout({
11552
+ label: "Volumetric Raymarch BGL",
11553
+ entries: [
11554
+ { binding: 0, visibility: GPUShaderStage.FRAGMENT | GPUShaderStage.VERTEX, buffer: { type: "uniform" } },
11555
+ { binding: 1, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: "depth" } },
11556
+ { binding: 2, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: "depth", viewDimension: "2d-array" } },
11557
+ { binding: 3, visibility: GPUShaderStage.FRAGMENT, sampler: { type: "comparison" } },
11558
+ { binding: 4, visibility: GPUShaderStage.FRAGMENT, buffer: { type: "read-only-storage" } },
11559
+ // cascade matrices
11560
+ { binding: 5, visibility: GPUShaderStage.FRAGMENT, buffer: { type: "read-only-storage" } },
11561
+ // lights
11562
+ { binding: 6, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: "depth" } },
11563
+ // spot shadow atlas
11564
+ { binding: 7, visibility: GPUShaderStage.FRAGMENT, buffer: { type: "read-only-storage" } }
11565
+ // spot matrices
11566
+ ]
11567
+ });
11568
+ this.raymarchPipeline = await device.createRenderPipelineAsync({
11569
+ label: "Volumetric Raymarch Pipeline",
11570
+ layout: device.createPipelineLayout({ bindGroupLayouts: [this.raymarchBGL] }),
11571
+ vertex: { module: raymarchModule, entryPoint: "vertexMain" },
11572
+ fragment: {
11573
+ module: raymarchModule,
11574
+ entryPoint: "fragmentMain",
11575
+ targets: [{ format: "rgba16float" }]
11576
+ },
11577
+ primitive: { topology: "triangle-list" }
11578
+ });
11579
+ const blurModule = device.createShaderModule({
11580
+ label: "Volumetric Blur Shader",
11581
+ code: volumetric_blur_default
11582
+ });
11583
+ this.blurBGL = device.createBindGroupLayout({
11584
+ label: "Volumetric Blur BGL",
11585
+ entries: [
11586
+ { binding: 0, visibility: GPUShaderStage.FRAGMENT | GPUShaderStage.VERTEX, buffer: { type: "uniform" } },
11587
+ { binding: 1, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: "float" } },
11588
+ { binding: 2, visibility: GPUShaderStage.FRAGMENT, sampler: { type: "filtering" } }
11589
+ ]
11590
+ });
11591
+ const blurPipelineDesc = {
11592
+ layout: device.createPipelineLayout({ bindGroupLayouts: [this.blurBGL] }),
11593
+ vertex: { module: blurModule, entryPoint: "vertexMain" },
11594
+ fragment: {
11595
+ module: blurModule,
11596
+ entryPoint: "fragmentMain",
11597
+ targets: [{ format: "rgba16float" }]
11598
+ },
11599
+ primitive: { topology: "triangle-list" }
11600
+ };
11601
+ this.blurHPipeline = await device.createRenderPipelineAsync({ ...blurPipelineDesc, label: "Volumetric Blur H" });
11602
+ this.blurVPipeline = await device.createRenderPipelineAsync({ ...blurPipelineDesc, label: "Volumetric Blur V" });
11603
+ const compositeModule = device.createShaderModule({
11604
+ label: "Volumetric Composite Shader",
11605
+ code: volumetric_composite_default
11606
+ });
11607
+ this.compositeBGL = device.createBindGroupLayout({
11608
+ label: "Volumetric Composite BGL",
11609
+ entries: [
11610
+ { binding: 0, visibility: GPUShaderStage.FRAGMENT | GPUShaderStage.VERTEX, buffer: { type: "uniform" } },
11611
+ { binding: 1, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: "float" } },
11612
+ { binding: 2, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: "float" } },
11613
+ { binding: 3, visibility: GPUShaderStage.FRAGMENT, sampler: { type: "filtering" } },
11614
+ { binding: 4, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: "depth" } }
11615
+ ]
11616
+ });
11617
+ this.compositePipeline = await device.createRenderPipelineAsync({
11618
+ label: "Volumetric Composite Pipeline",
11619
+ layout: device.createPipelineLayout({ bindGroupLayouts: [this.compositeBGL] }),
11620
+ vertex: { module: compositeModule, entryPoint: "vertexMain" },
11621
+ fragment: {
11622
+ module: compositeModule,
11623
+ entryPoint: "fragmentMain",
11624
+ targets: [{ format: "rgba16float" }]
11625
+ },
11626
+ primitive: { topology: "triangle-list" }
11627
+ });
11628
+ }
11629
+ async _execute(context) {
11630
+ if (!this.isVolumetricEnabled) return;
11631
+ if (!this.inputTexture || !this.gbuffer) return;
11632
+ const { device } = this.engine;
11633
+ const { camera, mainLight, lights } = context;
11634
+ const time = performance.now() / 1e3;
11635
+ this._updateAdaptiveScatter(camera, mainLight, time);
11636
+ const lightCount = lights?.length ?? this.lightingPass?.lightCount ?? 0;
11637
+ const commandEncoder = device.createCommandEncoder({ label: "Volumetric Fog Pass" });
11638
+ this._updateRaymarchUniforms(camera, mainLight, time, lightCount);
11639
+ const raymarchBindGroup = this._createRaymarchBindGroup();
11640
+ if (raymarchBindGroup) {
11641
+ const raymarchPass = commandEncoder.beginRenderPass({
11642
+ label: "Volumetric Raymarch",
11643
+ colorAttachments: [{
11644
+ view: this.raymarchTexture.view,
11645
+ loadOp: "clear",
11646
+ storeOp: "store",
11647
+ clearValue: { r: 0, g: 0, b: 0, a: 0 }
11648
+ }]
11649
+ });
11650
+ raymarchPass.setPipeline(this.raymarchPipeline);
11651
+ raymarchPass.setBindGroup(0, raymarchBindGroup);
11652
+ raymarchPass.draw(3);
11653
+ raymarchPass.end();
11654
+ }
11655
+ this._updateBlurUniforms(this.blurHUniformBuffer, 1, 0);
11656
+ this._updateBlurUniforms(this.blurVUniformBuffer, 0, 1);
11657
+ const blurHBindGroup = this._createBlurBindGroup(this.raymarchTexture, this.blurHUniformBuffer);
11658
+ const blurHPass = commandEncoder.beginRenderPass({
11659
+ label: "Volumetric Blur H",
11660
+ colorAttachments: [{
11661
+ view: this.blurTempTexture.view,
11662
+ loadOp: "clear",
11663
+ storeOp: "store",
11664
+ clearValue: { r: 0, g: 0, b: 0, a: 0 }
11665
+ }]
11666
+ });
11667
+ blurHPass.setPipeline(this.blurHPipeline);
11668
+ blurHPass.setBindGroup(0, blurHBindGroup);
11669
+ blurHPass.draw(3);
11670
+ blurHPass.end();
11671
+ const blurVBindGroup = this._createBlurBindGroup(this.blurTempTexture, this.blurVUniformBuffer);
11672
+ const blurVPass = commandEncoder.beginRenderPass({
11673
+ label: "Volumetric Blur V",
11674
+ colorAttachments: [{
11675
+ view: this.blurredTexture.view,
11676
+ loadOp: "clear",
11677
+ storeOp: "store",
11678
+ clearValue: { r: 0, g: 0, b: 0, a: 0 }
11679
+ }]
11680
+ });
11681
+ blurVPass.setPipeline(this.blurVPipeline);
11682
+ blurVPass.setBindGroup(0, blurVBindGroup);
11683
+ blurVPass.draw(3);
11684
+ blurVPass.end();
11685
+ this._updateCompositeUniforms();
11686
+ const compositeBindGroup = this._createCompositeBindGroup();
11687
+ if (compositeBindGroup) {
11688
+ const compositePass = commandEncoder.beginRenderPass({
11689
+ label: "Volumetric Composite",
11690
+ colorAttachments: [{
11691
+ view: this.outputTexture.view,
11692
+ loadOp: "clear",
11693
+ storeOp: "store",
11694
+ clearValue: { r: 0, g: 0, b: 0, a: 1 }
11695
+ }]
11696
+ });
11697
+ compositePass.setPipeline(this.compositePipeline);
11698
+ compositePass.setBindGroup(0, compositeBindGroup);
11699
+ compositePass.draw(3);
11700
+ compositePass.end();
11701
+ }
11702
+ device.queue.submit([commandEncoder.finish()]);
11703
+ }
11704
+ /**
11705
+ * Update adaptive main light scatter based on camera shadow state and sky visibility
11706
+ * Smoothly transitions between light/dark scatter values
11707
+ * Only uses dark scatter if camera is in shadow AND there's something overhead (no sky)
11708
+ */
11709
+ _updateAdaptiveScatter(camera, mainLight, currentTime) {
11710
+ if (this._currentMainLightScatter === null) {
11711
+ this._currentMainLightScatter = this.mainLightScatter;
11712
+ }
11713
+ const deltaTime = this._lastUpdateTime > 0 ? currentTime - this._lastUpdateTime : 0.016;
11714
+ this._lastUpdateTime = currentTime;
11715
+ let cameraInShadow = false;
11716
+ if (this.shadowPass && this.shadowsEnabled && mainLight?.enabled !== false) {
11717
+ const cameraPos = camera.position || [0, 0, 0];
11718
+ cameraInShadow = this._isCameraInShadow(cameraPos);
11719
+ if (!this._skyCheckPending && currentTime - this._lastSkyCheckTime > 0.5) {
11720
+ this._checkSkyVisibility(cameraPos);
11721
+ }
11722
+ }
11723
+ const inDarkArea = cameraInShadow && !this._skyVisible;
11724
+ const targetShadowState = inDarkArea ? 1 : 0;
11725
+ const transitionSpeed = inDarkArea ? 1 / 5 : 1 / 1;
11726
+ const t = 1 - Math.exp(-transitionSpeed * deltaTime * 3);
11727
+ this._cameraInShadowSmooth += (targetShadowState - this._cameraInShadowSmooth) * t;
11728
+ this._cameraInShadowSmooth = Math.max(0, Math.min(1, this._cameraInShadowSmooth));
11729
+ const lightScatter = this.mainLightScatter;
11730
+ const darkScatter = this.mainLightScatterDark;
11731
+ this._currentMainLightScatter = lightScatter + (darkScatter - lightScatter) * this._cameraInShadowSmooth;
11732
+ }
11733
+ /**
11734
+ * Check if sky is visible above the camera using raycaster
11735
+ * This is async and updates _skyVisible when complete
11736
+ */
11737
+ _checkSkyVisibility(cameraPos) {
11738
+ const raycaster = this.engine?.raycaster;
11739
+ if (!raycaster) return;
11740
+ this._skyCheckPending = true;
11741
+ this._lastSkyCheckTime = this._lastUpdateTime;
11742
+ const skyCheckDistance = this.volumetricSettings.skyCheckDistance ?? 100;
11743
+ const debugSkyCheck = this.volumetricSettings.debugSkyCheck ?? false;
11744
+ raycaster.cast(
11745
+ cameraPos,
11746
+ [0, 1, 0],
11747
+ // Straight up
11748
+ skyCheckDistance,
11749
+ (result) => {
11750
+ this._skyVisible;
11751
+ this._skyVisible = !result.hit;
11752
+ this._skyCheckPending = false;
11753
+ if (debugSkyCheck) {
11754
+ const pos = cameraPos.map((v) => v.toFixed(1)).join(", ");
11755
+ if (result.hit) {
11756
+ console.log(`Sky check from [${pos}]: HIT ${result.meshName || result.candidateId} at dist=${result.distance?.toFixed(1)}`);
11757
+ } else {
11758
+ console.log(`Sky check from [${pos}]: NO HIT (sky visible)`);
11759
+ }
11760
+ }
11761
+ },
11762
+ { backfaces: true, debug: debugSkyCheck }
11763
+ // Need backfaces to hit ceilings from below
11764
+ );
11765
+ }
11766
+ /**
11767
+ * Check if camera position is in shadow
11768
+ * Uses the shadow pass's isCameraInShadow method if available,
11769
+ * otherwise falls back to checking fog height bounds
11770
+ */
11771
+ _isCameraInShadow(cameraPos) {
11772
+ if (typeof this.shadowPass?.isCameraInShadow === "function") {
11773
+ return this.shadowPass.isCameraInShadow(cameraPos);
11774
+ }
11775
+ const heightRange = this.heightRange;
11776
+ const fogBottom = heightRange[0];
11777
+ const fogTop = heightRange[1];
11778
+ const lowerThird = fogBottom + (fogTop - fogBottom) * 0.33;
11779
+ if (cameraPos[1] < lowerThird) {
11780
+ return true;
11781
+ }
11782
+ const mainLightDir = this.engine?.settings?.mainLight?.direction;
11783
+ if (mainLightDir) {
11784
+ const sunAngle = mainLightDir[1];
11785
+ if (sunAngle > 0.7) {
11786
+ return true;
11787
+ }
11788
+ }
11789
+ return false;
11790
+ }
11791
+ _updateRaymarchUniforms(camera, mainLight, time, lightCount) {
11792
+ const { device } = this.engine;
11793
+ if (!camera.iProj || !camera.iView) {
11794
+ console.warn("VolumetricFogPass: Camera missing iProj or iView matrices");
11795
+ return;
11796
+ }
11797
+ const invProj = camera.iProj;
11798
+ const invView = camera.iView;
11799
+ const cameraPos = camera.position || [0, 0, 0];
11800
+ const mainLightEnabled = mainLight?.enabled !== false;
11801
+ const mainLightDir = mainLight?.direction ?? [-1, 1, -0.5];
11802
+ const mainLightColor = mainLight?.color ?? [1, 0.95, 0.9];
11803
+ const mainLightIntensity = mainLightEnabled ? mainLight?.intensity ?? 1 : 0;
11804
+ const fogColor = this.fogSettings.color ?? [0.8, 0.85, 0.9];
11805
+ const heightFade = this.heightRange;
11806
+ const shadowsEnabled = this.shadowsEnabled && this.shadowPass != null;
11807
+ const data = new Float32Array(64);
11808
+ let offset = 0;
11809
+ data.set(invProj, offset);
11810
+ offset += 16;
11811
+ data.set(invView, offset);
11812
+ offset += 16;
11813
+ data[offset++] = cameraPos[0];
11814
+ data[offset++] = cameraPos[1];
11815
+ data[offset++] = cameraPos[2];
11816
+ data[offset++] = camera.near ?? 0.1;
11817
+ data[offset++] = camera.far ?? 1e3;
11818
+ data[offset++] = this.maxSamples;
11819
+ data[offset++] = time;
11820
+ data[offset++] = this.fogDensity;
11821
+ data[offset++] = fogColor[0];
11822
+ data[offset++] = fogColor[1];
11823
+ data[offset++] = fogColor[2];
11824
+ data[offset++] = shadowsEnabled ? 1 : 0;
11825
+ data[offset++] = mainLightDir[0];
11826
+ data[offset++] = mainLightDir[1];
11827
+ data[offset++] = mainLightDir[2];
11828
+ data[offset++] = mainLightIntensity;
11829
+ data[offset++] = mainLightColor[0];
11830
+ data[offset++] = mainLightColor[1];
11831
+ data[offset++] = mainLightColor[2];
11832
+ data[offset++] = this.scatterStrength;
11833
+ data[offset++] = heightFade[0];
11834
+ data[offset++] = heightFade[1];
11835
+ data[offset++] = this.maxDistance;
11836
+ data[offset++] = lightCount;
11837
+ data[offset++] = this.debugMode;
11838
+ data[offset++] = this.noiseStrength;
11839
+ data[offset++] = this.noiseAnimated ? 1 : 0;
11840
+ data[offset++] = this._currentMainLightScatter;
11841
+ data[offset++] = this.noiseScale;
11842
+ data[offset++] = this.mainLightSaturation;
11843
+ device.queue.writeBuffer(this.raymarchUniformBuffer, 0, data);
11844
+ }
11845
+ _updateBlurUniforms(buffer, dirX, dirY) {
11846
+ const data = new Float32Array([
11847
+ dirX,
11848
+ dirY,
11849
+ 1 / this.renderWidth,
11850
+ 1 / this.renderHeight,
11851
+ this.blurRadius,
11852
+ 0,
11853
+ 0,
11854
+ 0
11855
+ ]);
11856
+ this.engine.device.queue.writeBuffer(buffer, 0, data);
11857
+ }
11858
+ _updateCompositeUniforms() {
11859
+ const data = new Float32Array([
11860
+ this.canvasWidth,
11861
+ this.canvasHeight,
11862
+ this.renderWidth,
11863
+ this.renderHeight,
11864
+ 1 / this.canvasWidth,
11865
+ 1 / this.canvasHeight,
11866
+ this.brightnessThreshold,
11867
+ this.minVisibility,
11868
+ this.skyBrightness,
11869
+ 0,
11870
+ // skyBrightness + padding
11871
+ 0,
11872
+ 0
11873
+ // extra padding to 48 bytes
11874
+ ]);
11875
+ this.engine.device.queue.writeBuffer(this.compositeUniformBuffer, 0, data);
11876
+ }
11877
+ _createRaymarchBindGroup() {
11878
+ const { device } = this.engine;
11879
+ const depthTexture = this.gbuffer?.depth;
11880
+ if (!depthTexture) return null;
11881
+ const cascadeShadows = this.shadowPass?.getShadowMap?.() ?? this.fallbackCascadeShadowMap;
11882
+ const cascadeMatrices = this.shadowPass?.getCascadeMatricesBuffer?.() ?? this.fallbackCascadeMatrices;
11883
+ const shadowSampler = this.shadowPass?.getShadowSampler?.() ?? this.fallbackShadowSampler;
11884
+ const lightsBuffer = this.lightingPass?.getLightBuffer?.() ?? this.fallbackLightsBuffer;
11885
+ const spotShadowAtlas = this.shadowPass?.getSpotShadowAtlasView?.() ?? this.fallbackSpotShadowAtlas.createView();
11886
+ const spotMatrices = this.shadowPass?.getSpotMatricesBuffer?.() ?? this.fallbackSpotMatrices;
11887
+ return device.createBindGroup({
11888
+ label: "Volumetric Raymarch Bind Group",
11889
+ layout: this.raymarchBGL,
11890
+ entries: [
11891
+ { binding: 0, resource: { buffer: this.raymarchUniformBuffer } },
11892
+ { binding: 1, resource: depthTexture.texture.createView({ aspect: "depth-only" }) },
11893
+ { binding: 2, resource: cascadeShadows.createView({ dimension: "2d-array", aspect: "depth-only" }) },
11894
+ { binding: 3, resource: shadowSampler },
11895
+ { binding: 4, resource: { buffer: cascadeMatrices } },
11896
+ { binding: 5, resource: { buffer: lightsBuffer } },
11897
+ { binding: 6, resource: spotShadowAtlas },
11898
+ { binding: 7, resource: { buffer: spotMatrices } }
11899
+ ]
11900
+ });
11901
+ }
11902
+ _createBlurBindGroup(inputTexture, uniformBuffer) {
11903
+ return this.engine.device.createBindGroup({
11904
+ label: "Volumetric Blur Bind Group",
11905
+ layout: this.blurBGL,
11906
+ entries: [
11907
+ { binding: 0, resource: { buffer: uniformBuffer } },
11908
+ { binding: 1, resource: inputTexture.view },
11909
+ { binding: 2, resource: this.linearSampler }
11910
+ ]
11911
+ });
11912
+ }
11913
+ _createCompositeBindGroup() {
11914
+ if (!this.inputTexture) return null;
11915
+ const depthTexture = this.gbuffer?.depth;
11916
+ if (!depthTexture) return null;
11917
+ return this.engine.device.createBindGroup({
11918
+ label: "Volumetric Composite Bind Group",
11919
+ layout: this.compositeBGL,
11920
+ entries: [
11921
+ { binding: 0, resource: { buffer: this.compositeUniformBuffer } },
11922
+ { binding: 1, resource: this.inputTexture.view },
11923
+ { binding: 2, resource: this.blurredTexture.view },
11924
+ { binding: 3, resource: this.linearSampler },
11925
+ { binding: 4, resource: depthTexture.texture.createView({ aspect: "depth-only" }) }
11926
+ ]
11927
+ });
11928
+ }
11929
+ _destroyTextures() {
11930
+ const textures = [this.raymarchTexture, this.blurTempTexture, this.blurredTexture, this.outputTexture];
11931
+ for (const tex of textures) {
11932
+ if (tex?.texture) tex.texture.destroy();
11933
+ }
11934
+ this.raymarchTexture = null;
11935
+ this.blurTempTexture = null;
11936
+ this.blurredTexture = null;
11937
+ this.outputTexture = null;
11938
+ }
11939
+ async _resize(width, height) {
11940
+ await this._createResources(width, height);
11941
+ }
11942
+ _destroy() {
11943
+ this._destroyTextures();
11944
+ const buffers = [this.raymarchUniformBuffer, this.blurHUniformBuffer, this.blurVUniformBuffer, this.compositeUniformBuffer];
11945
+ for (const buf of buffers) {
11946
+ if (buf) buf.destroy();
11947
+ }
11948
+ this.raymarchPipeline = null;
11949
+ this.blurHPipeline = null;
11950
+ this.blurVPipeline = null;
11951
+ this.compositePipeline = null;
11952
+ }
11953
+ }
11954
+ var postproc_default = "struct VertexOutput {\n @builtin(position) position : vec4<f32>,\n @location(0) uv : vec2<f32>,\n}\n\nstruct Uniforms {\n canvasSize: vec2f,\n noiseParams: vec4f, \n ditherParams: vec4f, \n bloomParams: vec4f, \n}\n\n@group(0) @binding(0) var<uniform> uniforms: Uniforms;\n@group(0) @binding(1) var inputTexture : texture_2d<f32>;\n@group(0) @binding(2) var inputSampler : sampler;\n@group(0) @binding(3) var noiseTexture : texture_2d<f32>;\n@group(0) @binding(4) var noiseSampler : sampler;\n@group(0) @binding(5) var bloomTexture : texture_2d<f32>;\n@group(0) @binding(6) var bloomSampler : sampler;\n@group(0) @binding(7) var guiTexture : texture_2d<f32>;\n@group(0) @binding(8) var guiSampler : sampler;\n\nconst FXAA_EDGE_THRESHOLD: f32 = 0.125; \nconst FXAA_EDGE_THRESHOLD_MIN: f32 = 0.0156; \nconst FXAA_SUBPIX_QUALITY: f32 = 1.0; \n\nfn sampleNoise(screenPos: vec2f) -> f32 {\n let noiseSize = i32(uniforms.noiseParams.x);\n let noiseOffsetX = i32(uniforms.noiseParams.y * f32(noiseSize));\n let noiseOffsetY = i32(uniforms.noiseParams.z * f32(noiseSize));\n\n let texCoord = vec2i(\n (i32(screenPos.x) + noiseOffsetX) % noiseSize,\n (i32(screenPos.y) + noiseOffsetY) % noiseSize\n );\n return textureLoad(noiseTexture, texCoord, 0).r;\n}\n\nfn rgb2luma(rgb: vec3f) -> f32 {\n return dot(rgb, vec3f(0.299, 0.587, 0.114));\n}\n\nfn loadPixel(coord: vec2i) -> vec3f {\n let size = vec2i(textureDimensions(inputTexture, 0));\n let clampedCoord = clamp(coord, vec2i(0), size - vec2i(1));\n return textureLoad(inputTexture, clampedCoord, 0).rgb;\n}\n\nfn sampleBilinear(uv: vec2f) -> vec3f {\n let texSize = vec2f(textureDimensions(inputTexture, 0));\n let texelPos = uv * texSize - 0.5;\n let baseCoord = vec2i(floor(texelPos));\n let frac = fract(texelPos);\n\n let c00 = loadPixel(baseCoord);\n let c10 = loadPixel(baseCoord + vec2i(1, 0));\n let c01 = loadPixel(baseCoord + vec2i(0, 1));\n let c11 = loadPixel(baseCoord + vec2i(1, 1));\n\n let c0 = mix(c00, c10, frac.x);\n let c1 = mix(c01, c11, frac.x);\n return mix(c0, c1, frac.y);\n}\n\nfn fxaa(uv: vec2f) -> vec3f {\n let texSize = vec2f(textureDimensions(inputTexture, 0));\n let pixelCoord = vec2i(uv * texSize);\n\n \n let rgbM = loadPixel(pixelCoord);\n let rgbN = loadPixel(pixelCoord + vec2i(0, -1));\n let rgbS = loadPixel(pixelCoord + vec2i(0, 1));\n let rgbW = loadPixel(pixelCoord + vec2i(-1, 0));\n let rgbE = loadPixel(pixelCoord + vec2i(1, 0));\n let rgbNW = loadPixel(pixelCoord + vec2i(-1, -1));\n let rgbNE = loadPixel(pixelCoord + vec2i(1, -1));\n let rgbSW = loadPixel(pixelCoord + vec2i(-1, 1));\n let rgbSE = loadPixel(pixelCoord + vec2i(1, 1));\n\n \n let lumaM = rgb2luma(rgbM);\n let lumaN = rgb2luma(rgbN);\n let lumaS = rgb2luma(rgbS);\n let lumaW = rgb2luma(rgbW);\n let lumaE = rgb2luma(rgbE);\n let lumaNW = rgb2luma(rgbNW);\n let lumaNE = rgb2luma(rgbNE);\n let lumaSW = rgb2luma(rgbSW);\n let lumaSE = rgb2luma(rgbSE);\n\n \n let lumaMin = min(lumaM, min(min(lumaN, lumaS), min(lumaW, lumaE)));\n let lumaMax = max(lumaM, max(max(lumaN, lumaS), max(lumaW, lumaE)));\n let lumaRange = lumaMax - lumaMin;\n\n \n let isEdge = lumaRange >= max(FXAA_EDGE_THRESHOLD_MIN, lumaMax * FXAA_EDGE_THRESHOLD);\n\n \n let lumaL = (lumaN + lumaS + lumaW + lumaE) * 0.25;\n let rangeL = abs(lumaL - lumaM);\n var blendL = max(0.0, (rangeL / max(lumaRange, 0.0001)) - 0.25) * (1.0 / 0.75);\n blendL = min(1.0, blendL) * blendL * FXAA_SUBPIX_QUALITY;\n\n \n let edgeHorz = abs((lumaNW + lumaNE) - 2.0 * lumaN) +\n 2.0 * abs((lumaW + lumaE) - 2.0 * lumaM) +\n abs((lumaSW + lumaSE) - 2.0 * lumaS);\n let edgeVert = abs((lumaNW + lumaSW) - 2.0 * lumaW) +\n 2.0 * abs((lumaN + lumaS) - 2.0 * lumaM) +\n abs((lumaNE + lumaSE) - 2.0 * lumaE);\n let isHorizontal = edgeHorz >= edgeVert;\n\n \n let luma1 = select(lumaE, lumaS, isHorizontal);\n let luma2 = select(lumaW, lumaN, isHorizontal);\n let gradient1 = abs(luma1 - lumaM);\n let gradient2 = abs(luma2 - lumaM);\n let is1Steepest = gradient1 >= gradient2;\n let gradientScaled = 0.25 * max(gradient1, gradient2);\n\n \n let stepSign = select(-1.0, 1.0, is1Steepest);\n let lumaLocalAverage = 0.5 * (select(luma2, luma1, is1Steepest) + lumaM);\n\n \n let searchDir = select(vec2i(0, 1), vec2i(1, 0), isHorizontal);\n\n let luma1_1 = rgb2luma(loadPixel(pixelCoord - searchDir)) - lumaLocalAverage;\n let luma2_1 = rgb2luma(loadPixel(pixelCoord + searchDir)) - lumaLocalAverage;\n let luma1_2 = rgb2luma(loadPixel(pixelCoord - searchDir * 2)) - lumaLocalAverage;\n let luma2_2 = rgb2luma(loadPixel(pixelCoord + searchDir * 2)) - lumaLocalAverage;\n let luma1_3 = rgb2luma(loadPixel(pixelCoord - searchDir * 3)) - lumaLocalAverage;\n let luma2_3 = rgb2luma(loadPixel(pixelCoord + searchDir * 3)) - lumaLocalAverage;\n let luma1_4 = rgb2luma(loadPixel(pixelCoord - searchDir * 4)) - lumaLocalAverage;\n let luma2_4 = rgb2luma(loadPixel(pixelCoord + searchDir * 4)) - lumaLocalAverage;\n\n \n let reached1_1 = abs(luma1_1) >= gradientScaled;\n let reached1_2 = abs(luma1_2) >= gradientScaled;\n let reached1_3 = abs(luma1_3) >= gradientScaled;\n let reached2_1 = abs(luma2_1) >= gradientScaled;\n let reached2_2 = abs(luma2_2) >= gradientScaled;\n let reached2_3 = abs(luma2_3) >= gradientScaled;\n\n \n let dist1 = select(select(select(4.0, 3.0, reached1_3), 2.0, reached1_2), 1.0, reached1_1);\n let dist2 = select(select(select(4.0, 3.0, reached2_3), 2.0, reached2_2), 1.0, reached2_1);\n\n \n let lumaEnd1 = select(select(select(luma1_4, luma1_3, reached1_3), luma1_2, reached1_2), luma1_1, reached1_1);\n let lumaEnd2 = select(select(select(luma2_4, luma2_3, reached2_3), luma2_2, reached2_2), luma2_1, reached2_1);\n\n \n let distFinal = min(dist1, dist2);\n let edgeThickness = dist1 + dist2;\n let lumaEndCloser = select(lumaEnd2, lumaEnd1, dist1 < dist2);\n let correctVariation = (lumaEndCloser < 0.0) != (lumaM < lumaLocalAverage);\n var pixelOffset = select(0.0, -distFinal / max(edgeThickness, 0.0001) + 0.5, correctVariation);\n\n \n let finalOffset = max(max(pixelOffset, blendL), 0.5);\n\n \n let inverseVP = 1.0 / texSize;\n var finalUv = uv;\n let offsetAmount = finalOffset * stepSign;\n finalUv.x += select(offsetAmount * inverseVP.x, 0.0, isHorizontal);\n finalUv.y += select(0.0, offsetAmount * inverseVP.y, isHorizontal);\n\n \n let offsetColor = sampleBilinear(finalUv);\n\n \n \n let perpDir = select(vec2i(0, 1), vec2i(1, 0), isHorizontal);\n let perpColor1 = loadPixel(pixelCoord + perpDir);\n let perpColor2 = loadPixel(pixelCoord - perpDir);\n let neighborAvg = (perpColor1 + perpColor2) * 0.5;\n\n \n let fxaaColor = mix(mix(offsetColor, neighborAvg, 0.7), rgbM, 0.1);\n\n \n return select(rgbM, fxaaColor, isEdge);\n}\n\nfn sampleBloom(uv: vec2f, sceneBrightness: f32) -> vec3f {\n let bloom = textureSample(bloomTexture, bloomSampler, uv).rgb;\n\n \n \n let threshold = 0.5; \n let mask = saturate(1.0 - (sceneBrightness - threshold) / (1.0 - threshold));\n\n return bloom * mask * mask; \n}\n\nfn aces_tone_map(hdr: vec3<f32>) -> vec3<f32> {\n let m1 = mat3x3(\n 0.59719, 0.07600, 0.02840,\n 0.35458, 0.90834, 0.13383,\n 0.04823, 0.01566, 0.83777,\n );\n let m2 = mat3x3(\n 1.60475, -0.10208, -0.00327,\n -0.53108, 1.10813, -0.07276,\n -0.07367, -0.00605, 1.07602,\n );\n let v = m1 * hdr;\n let a = v * (v + 0.0245786) - 0.000090537;\n let b = v * (0.983729 * v + 0.4329510) + 0.238081;\n return clamp(m2 * (a / b), vec3(0.0), vec3(1.0));\n}\n\nfn reinhard_tone_map(hdr: vec3<f32>) -> vec3<f32> {\n return hdr / (hdr + vec3(1.0));\n}\n\nfn linear_tone_map(hdr: vec3<f32>) -> vec3<f32> {\n return clamp(hdr, vec3(0.0), vec3(1.0));\n}\n\nfn apply_tone_map(hdr: vec3<f32>, mode: i32) -> vec3<f32> {\n if (mode == 1) {\n return reinhard_tone_map(hdr);\n } else if (mode == 2) {\n return linear_tone_map(hdr);\n }\n return aces_tone_map(hdr); \n}\n\n@vertex\nfn vertexMain(@builtin(vertex_index) vertexIndex : u32) -> VertexOutput {\n var output : VertexOutput;\n let x = f32(vertexIndex & 1u) * 4.0 - 1.0;\n let y = f32(vertexIndex >> 1u) * 4.0 - 1.0;\n output.position = vec4<f32>(x, y, 0.0, 1.0);\n output.uv = vec2<f32>((x + 1.0) * 0.5, (1.0 - y) * 0.5);\n return output;\n}\n\n@fragment\nfn fragmentMain(input: VertexOutput) -> @location(0) vec4<f32> {\n \n let fxaaEnabled = uniforms.noiseParams.w > 0.5;\n let fxaaColor = fxaa(input.uv);\n let directColor = textureSample(inputTexture, inputSampler, input.uv).rgb;\n var color = select(directColor, fxaaColor, fxaaEnabled);\n\n \n \n if (uniforms.bloomParams.x > 0.5) {\n let sceneBrightness = rgb2luma(color);\n let bloom = sampleBloom(input.uv, sceneBrightness);\n color += bloom * uniforms.bloomParams.y; \n }\n\n let tonemapMode = i32(uniforms.ditherParams.z);\n var sdr = apply_tone_map(color, tonemapMode);\n\n \n \n let gui = textureSample(guiTexture, guiSampler, input.uv);\n sdr = sdr * (1.0 - gui.a) + gui.rgb;\n\n \n \n if (uniforms.ditherParams.x > 0.5 && uniforms.noiseParams.x > 0.0) {\n let levels = uniforms.ditherParams.y;\n let noise = sampleNoise(input.position.xy);\n\n \n \n let dither = noise - 0.5;\n sdr = floor(sdr * (levels - 1.0) + dither + 0.5) / (levels - 1.0);\n sdr = clamp(sdr, vec3f(0.0), vec3f(1.0));\n }\n\n return vec4<f32>(sdr, 1.0);\n}";
11955
+ class PostProcessPass extends BasePass {
11956
+ constructor(engine = null) {
11957
+ super("PostProcess", engine);
11958
+ this.pipeline = null;
11959
+ this.inputTexture = null;
11960
+ this.bloomTexture = null;
11961
+ this.dummyBloomTexture = null;
11962
+ this.noiseTexture = null;
11963
+ this.noiseSize = 64;
11964
+ this.noiseAnimated = true;
11965
+ this.guiCanvas = null;
11966
+ this.guiTexture = null;
11967
+ this.guiSampler = null;
11968
+ this.intermediateTexture = null;
11969
+ this._outputWidth = 0;
11970
+ this._outputHeight = 0;
11971
+ this._lastOutputToTexture = false;
11972
+ this._resizeWidth = 0;
11973
+ this._resizeHeight = 0;
11974
+ }
11975
+ // Convenience getter for exposure setting
11976
+ get exposure() {
11977
+ return this.settings?.environment?.exposure ?? 1.6;
11978
+ }
11979
+ // Convenience getter for fxaa setting
11980
+ get fxaa() {
11981
+ return this.settings?.rendering?.fxaa ?? true;
11982
+ }
11983
+ // Convenience getter for dithering settings
11984
+ get ditheringEnabled() {
11985
+ return this.settings?.dithering?.enabled ?? true;
11986
+ }
11987
+ get colorLevels() {
11988
+ return this.settings?.dithering?.colorLevels ?? 32;
11989
+ }
11990
+ // Tonemap mode: 0=ACES, 1=Reinhard, 2=None/Linear
11991
+ get tonemapMode() {
11992
+ return this.settings?.rendering?.tonemapMode ?? 0;
11993
+ }
11994
+ // Convenience getters for bloom settings
11995
+ get bloomEnabled() {
11996
+ return this.settings?.bloom?.enabled ?? true;
11997
+ }
11998
+ get bloomIntensity() {
11999
+ return this.settings?.bloom?.intensity ?? 1;
12000
+ }
12001
+ get bloomRadius() {
12002
+ return this.settings?.bloom?.radius ?? 5;
12003
+ }
12004
+ // CRT settings (determines if we output to intermediate texture)
12005
+ get crtEnabled() {
12006
+ return this.settings?.crt?.enabled ?? false;
12007
+ }
12008
+ get crtUpscaleEnabled() {
12009
+ return this.settings?.crt?.upscaleEnabled ?? false;
12010
+ }
12011
+ get shouldOutputToTexture() {
12012
+ return this.crtEnabled || this.crtUpscaleEnabled;
12013
+ }
12014
+ /**
12015
+ * Set the input texture (HDR image from LightingPass)
12016
+ * @param {Texture} texture - Input HDR texture
12017
+ */
12018
+ setInputTexture(texture) {
12019
+ if (this.inputTexture !== texture) {
12020
+ this.inputTexture = texture;
12021
+ this._needsRebuild = true;
12022
+ }
12023
+ }
12024
+ /**
12025
+ * Set the bloom texture (from BloomPass)
12026
+ * @param {Object} bloomTexture - Bloom texture with mip levels
12027
+ */
12028
+ setBloomTexture(bloomTexture) {
12029
+ if (this.bloomTexture !== bloomTexture) {
12030
+ this.bloomTexture = bloomTexture;
12031
+ this._needsRebuild = true;
12032
+ }
12033
+ }
12034
+ /**
12035
+ * Set the noise texture for dithering
12036
+ * @param {Texture} texture - Noise texture (blue noise or bayer dither)
12037
+ * @param {number} size - Texture size
12038
+ * @param {boolean} animated - Whether to animate noise offset each frame
12039
+ */
12040
+ setNoise(texture, size = 64, animated = true) {
12041
+ this.noiseTexture = texture;
12042
+ this.noiseSize = size;
12043
+ this.noiseAnimated = animated;
12044
+ this._needsRebuild = true;
12045
+ }
12046
+ /**
12047
+ * Set the GUI canvas for overlay rendering
12048
+ * @param {HTMLCanvasElement} canvas - 2D canvas with GUI content
12049
+ */
12050
+ setGuiCanvas(canvas) {
12051
+ this.guiCanvas = canvas;
12052
+ }
12053
+ /**
12054
+ * Get the output texture (for CRT pass to use)
12055
+ * Returns null if outputting directly to canvas
12056
+ */
12057
+ getOutputTexture() {
12058
+ return this.shouldOutputToTexture ? this.intermediateTexture : null;
12059
+ }
12060
+ /**
12061
+ * Create or resize the intermediate texture for CRT
12062
+ */
12063
+ async _createIntermediateTexture(width, height) {
12064
+ if (!this.shouldOutputToTexture) {
12065
+ if (this.intermediateTexture?.texture) {
12066
+ this.intermediateTexture.texture.destroy();
12067
+ this.intermediateTexture = null;
12068
+ }
12069
+ return;
12070
+ }
12071
+ if (this._outputWidth === width && this._outputHeight === height && this.intermediateTexture) {
12072
+ return;
12073
+ }
12074
+ const { device } = this.engine;
12075
+ if (this.intermediateTexture?.texture) {
12076
+ this.intermediateTexture.texture.destroy();
12077
+ }
12078
+ const texture = device.createTexture({
12079
+ label: "PostProcess Intermediate",
12080
+ size: [width, height, 1],
12081
+ format: "rgba8unorm",
12082
+ usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.COPY_SRC
12083
+ });
12084
+ const sampler = device.createSampler({
12085
+ label: "PostProcess Intermediate Sampler",
12086
+ minFilter: "nearest",
12087
+ magFilter: "nearest"
12088
+ });
12089
+ this.intermediateTexture = {
12090
+ texture,
12091
+ view: texture.createView(),
12092
+ sampler,
12093
+ width,
12094
+ height,
12095
+ format: "rgba8unorm"
12096
+ // Required by Pipeline.create()
12097
+ };
12098
+ this._outputWidth = width;
12099
+ this._outputHeight = height;
12100
+ this._needsRebuild = true;
12101
+ console.log(`PostProcessPass: Created intermediate texture ${width}x${height} for CRT`);
12102
+ }
12103
+ async _init() {
12104
+ const { device } = this.engine;
12105
+ const dummyTexture = device.createTexture({
12106
+ label: "Dummy Bloom Texture",
12107
+ size: [1, 1, 1],
12108
+ format: "rgba16float",
12109
+ mipLevelCount: 1,
12110
+ usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST
12111
+ });
12112
+ device.queue.writeTexture(
12113
+ { texture: dummyTexture },
12114
+ new Float32Array([0, 0, 0, 0]).buffer,
12115
+ { bytesPerRow: 8 },
12116
+ { width: 1, height: 1 }
12117
+ );
12118
+ const dummySampler = device.createSampler({
12119
+ label: "Dummy Bloom Sampler",
12120
+ minFilter: "linear",
12121
+ magFilter: "linear"
12122
+ });
12123
+ this.dummyBloomTexture = {
12124
+ texture: dummyTexture,
12125
+ view: dummyTexture.createView(),
12126
+ sampler: dummySampler,
12127
+ mipCount: 1
12128
+ };
12129
+ this.guiSampler = device.createSampler({
12130
+ label: "GUI Sampler",
12131
+ minFilter: "linear",
12132
+ magFilter: "linear"
12133
+ });
12134
+ const dummyGuiTexture = device.createTexture({
12135
+ label: "Dummy GUI Texture",
12136
+ size: [1, 1, 1],
12137
+ format: "rgba8unorm",
12138
+ usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST
12139
+ });
12140
+ device.queue.writeTexture(
12141
+ { texture: dummyGuiTexture },
12142
+ new Uint8Array([0, 0, 0, 0]),
12143
+ { bytesPerRow: 4 },
12144
+ { width: 1, height: 1 }
12145
+ );
12146
+ this.dummyGuiTexture = {
12147
+ texture: dummyGuiTexture,
12148
+ view: dummyGuiTexture.createView(),
12149
+ sampler: this.guiSampler
12150
+ };
12151
+ }
12152
+ /**
12153
+ * Build or rebuild the pipeline
12154
+ */
12155
+ async _buildPipeline() {
12156
+ if (!this.inputTexture) {
12157
+ return;
12158
+ }
12159
+ const textures = [this.inputTexture];
12160
+ if (this.noiseTexture) {
12161
+ textures.push(this.noiseTexture);
12162
+ }
12163
+ const effectiveBloomTexture = this.bloomTexture || this.dummyBloomTexture;
12164
+ textures.push(effectiveBloomTexture);
12165
+ const effectiveGuiTexture = this.guiTexture || this.dummyGuiTexture;
12166
+ textures.push(effectiveGuiTexture);
12167
+ const hasBloom = this.bloomTexture && this.bloomEnabled;
12168
+ const renderTarget = this.shouldOutputToTexture && this.intermediateTexture ? this.intermediateTexture : null;
11177
12169
  this.pipeline = await Pipeline.create(this.engine, {
11178
12170
  label: "postProcess",
11179
12171
  wgslSource: postproc_default,
@@ -11181,75 +12173,662 @@ class PostProcessPass extends BasePass {
11181
12173
  textures,
11182
12174
  uniforms: () => ({
11183
12175
  noiseParams: [this.noiseSize, this.noiseAnimated ? Math.random() : 0, this.noiseAnimated ? Math.random() : 0, this.fxaa ? 1 : 0],
11184
- ditherParams: [this.ditheringEnabled ? 1 : 0, this.colorLevels, 0, 0],
12176
+ ditherParams: [this.ditheringEnabled ? 1 : 0, this.colorLevels, this.tonemapMode, 0],
11185
12177
  bloomParams: [hasBloom ? 1 : 0, this.bloomIntensity, this.bloomRadius, effectiveBloomTexture?.mipCount ?? 1]
11186
- })
11187
- // No renderTarget = output to canvas
12178
+ }),
12179
+ renderTarget
12180
+ });
12181
+ this._needsRebuild = false;
12182
+ }
12183
+ async _execute(context) {
12184
+ const { device, canvas } = this.engine;
12185
+ const needsOutputToTexture = this.shouldOutputToTexture;
12186
+ const hasIntermediateTexture = !!this.intermediateTexture;
12187
+ if (needsOutputToTexture !== this._lastOutputToTexture) {
12188
+ this._lastOutputToTexture = needsOutputToTexture;
12189
+ this._needsRebuild = true;
12190
+ if (needsOutputToTexture && !hasIntermediateTexture) {
12191
+ const w = this._resizeWidth || canvas.width;
12192
+ const h = this._resizeHeight || canvas.height;
12193
+ await this._createIntermediateTexture(w, h);
12194
+ } else if (!needsOutputToTexture && hasIntermediateTexture) {
12195
+ if (this.intermediateTexture?.texture) {
12196
+ this.intermediateTexture.texture.destroy();
12197
+ this.intermediateTexture = null;
12198
+ }
12199
+ }
12200
+ }
12201
+ if (this.guiCanvas && this.guiCanvas.width > 0 && this.guiCanvas.height > 0) {
12202
+ const needsNewTexture = !this.guiTexture || this.guiTexture.width !== this.guiCanvas.width || this.guiTexture.height !== this.guiCanvas.height;
12203
+ if (needsNewTexture) {
12204
+ if (this.guiTexture?.texture) {
12205
+ this.guiTexture.texture.destroy();
12206
+ }
12207
+ const texture = device.createTexture({
12208
+ label: "GUI Texture",
12209
+ size: [this.guiCanvas.width, this.guiCanvas.height, 1],
12210
+ format: "rgba8unorm",
12211
+ usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST | GPUTextureUsage.RENDER_ATTACHMENT
12212
+ });
12213
+ this.guiTexture = {
12214
+ texture,
12215
+ view: texture.createView(),
12216
+ sampler: this.guiSampler,
12217
+ width: this.guiCanvas.width,
12218
+ height: this.guiCanvas.height
12219
+ };
12220
+ this._needsRebuild = true;
12221
+ }
12222
+ device.queue.copyExternalImageToTexture(
12223
+ { source: this.guiCanvas },
12224
+ { texture: this.guiTexture.texture },
12225
+ [this.guiCanvas.width, this.guiCanvas.height]
12226
+ );
12227
+ }
12228
+ if (this._needsRebuild) {
12229
+ await this._buildPipeline();
12230
+ }
12231
+ if (!this.pipeline || this._needsRebuild) {
12232
+ console.warn("PostProcessPass: Pipeline not ready");
12233
+ return;
12234
+ }
12235
+ const hasBloom = this.bloomTexture && this.bloomEnabled;
12236
+ const effectiveBloomTexture = this.bloomTexture || this.dummyBloomTexture;
12237
+ this.pipeline.uniformValues.set({
12238
+ noiseParams: [this.noiseSize, this.noiseAnimated ? Math.random() : 0, this.noiseAnimated ? Math.random() : 0, this.fxaa ? 1 : 0],
12239
+ ditherParams: [this.ditheringEnabled ? 1 : 0, this.colorLevels, this.tonemapMode, 0],
12240
+ bloomParams: [hasBloom ? 1 : 0, this.bloomIntensity, this.bloomRadius, effectiveBloomTexture?.mipCount ?? 1]
12241
+ });
12242
+ this.pipeline.render();
12243
+ }
12244
+ async _resize(width, height) {
12245
+ this._resizeWidth = width;
12246
+ this._resizeHeight = height;
12247
+ await this._createIntermediateTexture(width, height);
12248
+ this._needsRebuild = true;
12249
+ }
12250
+ _destroy() {
12251
+ this.pipeline = null;
12252
+ if (this.dummyBloomTexture?.texture) {
12253
+ this.dummyBloomTexture.texture.destroy();
12254
+ this.dummyBloomTexture = null;
12255
+ }
12256
+ if (this.guiTexture?.texture) {
12257
+ this.guiTexture.texture.destroy();
12258
+ this.guiTexture = null;
12259
+ }
12260
+ if (this.dummyGuiTexture?.texture) {
12261
+ this.dummyGuiTexture.texture.destroy();
12262
+ this.dummyGuiTexture = null;
12263
+ }
12264
+ if (this.intermediateTexture?.texture) {
12265
+ this.intermediateTexture.texture.destroy();
12266
+ this.intermediateTexture = null;
12267
+ }
12268
+ }
12269
+ }
12270
+ var crt_default = "struct VertexOutput {\n @builtin(position) position: vec4f,\n @location(0) uv: vec2f,\n}\n\nstruct Uniforms {\n canvasSize: vec2f, \n inputSize: vec2f, \n renderSize: vec2f, \n\n \n curvature: f32, \n cornerRadius: f32, \n zoom: f32, \n _padGeom: f32,\n\n \n scanlineIntensity: f32, \n scanlineWidth: f32, \n scanlineBrightBoost: f32, \n scanlineHeight: f32, \n\n \n convergence: vec3f,\n _pad2: f32,\n\n \n maskType: f32, \n maskIntensity: f32, \n maskScale: f32, \n maskCompensation: f32, \n\n \n vignetteIntensity: f32,\n vignetteSize: f32,\n\n \n blurSize: f32, \n\n \n crtEnabled: f32, \n upscaleEnabled: f32, \n}\n\n@group(0) @binding(0) var<uniform> uniforms: Uniforms;\n@group(0) @binding(1) var inputTexture: texture_2d<f32>;\n@group(0) @binding(2) var inputSampler: sampler; \n@group(0) @binding(3) var nearestSampler: sampler; \n@group(0) @binding(4) var phosphorMask: texture_2d<f32>;\n@group(0) @binding(5) var phosphorSampler: sampler;\n\nconst PI: f32 = 3.14159265359;\n\nfn applyBarrelDistortion(uv: vec2f, curvature: f32) -> vec2f {\n \n let centered = uv - 0.5;\n\n \n let r2 = dot(centered, centered);\n\n \n let distorted = centered * (1.0 + curvature * r2);\n\n return distorted + 0.5;\n}\n\nfn getCornerMask(uv: vec2f, radius: f32) -> f32 {\n \n let d = abs(uv - 0.5) * 2.0;\n\n \n let corner = max(d.x, d.y);\n let roundedCorner = length(max(d - (1.0 - radius), vec2f(0.0)));\n\n \n let mask = 1.0 - smoothstep(1.0 - radius * 0.5, 1.0, max(corner, roundedCorner));\n\n return mask;\n}\n\nfn applyScanlines(color: vec3f, scanlinePos: f32, intensity: f32, width: f32, brightBoost: f32) -> vec3f {\n if (intensity <= 0.0) {\n return color;\n }\n\n \n \n let pos = fract(scanlinePos);\n\n \n \n let distFromCenter = abs(pos - 0.5);\n\n \n let brightness = dot(color, vec3f(0.299, 0.587, 0.114));\n\n \n \n \n let baseWidth = width;\n let brightWidening = brightBoost * brightness * (1.0 - width);\n let effectiveWidth = clamp(baseWidth + brightWidening, 0.0, 1.0);\n\n \n \n \n let sigma = mix(0.08, 0.8, effectiveWidth);\n let gaussian = exp(-0.5 * pow(distFromCenter / sigma, 2.0));\n\n \n let scanline = clamp(gaussian, 0.0, 1.0);\n let darkening = mix(1.0, scanline, intensity);\n\n return color * darkening;\n}\n\nfn sampleWithConvergence(uv: vec2f, convergence: vec3f, texelSize: vec2f) -> vec3f {\n \n \n let offsetR = vec2f(convergence.r * texelSize.x, 0.0);\n let offsetG = vec2f(convergence.g * texelSize.x, 0.0);\n let offsetB = vec2f(convergence.b * texelSize.x, 0.0);\n\n let r = textureSampleLevel(inputTexture, inputSampler, uv + offsetR, 0.0).r;\n let g = textureSampleLevel(inputTexture, inputSampler, uv + offsetG, 0.0).g;\n let b = textureSampleLevel(inputTexture, inputSampler, uv + offsetB, 0.0).b;\n\n return vec3f(r, g, b);\n}\n\nfn applyPhosphorMask(color: vec3f, screenPos: vec2f, maskType: f32, intensity: f32, scale: f32) -> vec3f {\n if (intensity <= 0.0 || maskType < 0.5) {\n return color;\n }\n\n \n \n \n let maskUV = screenPos / (3.0 * scale);\n let mask = textureSampleLevel(phosphorMask, phosphorSampler, maskUV, 0.0).rgb;\n\n \n \n return mix(color, color * mask, intensity);\n}\n\nfn proceduralApertureGrille(screenX: f32, scale: f32) -> vec3f {\n let phase = fract(screenX / (3.0 * scale));\n\n \n var mask = vec3f(0.0);\n if (phase < 0.333) {\n mask = vec3f(1.0, 0.2, 0.2); \n } else if (phase < 0.666) {\n mask = vec3f(0.2, 1.0, 0.2); \n } else {\n mask = vec3f(0.2, 0.2, 1.0); \n }\n\n return mask;\n}\n\nfn proceduralSlotMask(screenPos: vec2f, scale: f32, scanlineHeight: f32) -> vec3f {\n \n let cellWidth = 3.0 * scale;\n let cellHeight = max(scanlineHeight, 1.0);\n let cellPos = fract(vec2f(screenPos.x / cellWidth, screenPos.y / cellHeight));\n\n \n var adjusted = cellPos;\n if (fract(screenPos.y / (cellHeight * 2.0)) > 0.5) {\n adjusted.x = fract(adjusted.x + 0.5);\n }\n\n \n var mask = vec3f(0.0);\n if (adjusted.x < 0.333) {\n mask = vec3f(1.0, 0.1, 0.1);\n } else if (adjusted.x < 0.666) {\n mask = vec3f(0.1, 1.0, 0.1);\n } else {\n mask = vec3f(0.1, 0.1, 1.0);\n }\n\n \n mask *= smoothstep(0.0, 0.15, cellPos.y) * smoothstep(1.0, 0.85, cellPos.y);\n\n return mask;\n}\n\nfn proceduralShadowMask(screenPos: vec2f, scale: f32) -> vec3f {\n let cellSize = 2.0 * scale;\n let cell = floor(screenPos / cellSize);\n let cellPos = fract(screenPos / cellSize);\n\n \n let rowOffset = select(0.0, 0.5, fract(cell.y * 0.5) > 0.25);\n let adjustedX = fract(cellPos.x + rowOffset);\n\n \n let dist = length(cellPos - 0.5) * 2.0;\n let circle = 1.0 - smoothstep(0.6, 0.8, dist);\n\n \n let phase = fract((cell.x + rowOffset) / 3.0);\n var mask = vec3f(0.1);\n if (phase < 0.333) {\n mask = vec3f(1.0, 0.1, 0.1) * circle;\n } else if (phase < 0.666) {\n mask = vec3f(0.1, 1.0, 0.1) * circle;\n } else {\n mask = vec3f(0.1, 0.1, 1.0) * circle;\n }\n\n return max(mask, vec3f(0.1));\n}\n\nfn applyVignette(color: vec3f, uv: vec2f, intensity: f32, size: f32) -> vec3f {\n if (intensity <= 0.0) {\n return color;\n }\n\n \n let centered = uv - 0.5;\n let dist = length(centered);\n\n \n let vignette = 1.0 - smoothstep(size * 0.5, size, dist);\n let darkening = mix(1.0 - intensity, 1.0, vignette);\n\n return color * darkening;\n}\n\nfn sampleWithHorizontalBlur(uv: vec2f, blurSize: f32, texelSize: vec2f) -> vec3f {\n if (blurSize <= 0.0) {\n return textureSampleLevel(inputTexture, inputSampler, uv, 0.0).rgb;\n }\n\n \n \n let w0 = 0.0625;\n let w1 = 0.25;\n let w2 = 0.375;\n\n let offset = blurSize * texelSize.x;\n\n let c0 = textureSampleLevel(inputTexture, inputSampler, uv + vec2f(-2.0 * offset, 0.0), 0.0).rgb;\n let c1 = textureSampleLevel(inputTexture, inputSampler, uv + vec2f(-1.0 * offset, 0.0), 0.0).rgb;\n let c2 = textureSampleLevel(inputTexture, inputSampler, uv, 0.0).rgb;\n let c3 = textureSampleLevel(inputTexture, inputSampler, uv + vec2f(1.0 * offset, 0.0), 0.0).rgb;\n let c4 = textureSampleLevel(inputTexture, inputSampler, uv + vec2f(2.0 * offset, 0.0), 0.0).rgb;\n\n return c0 * w0 + c1 * w1 + c2 * w2 + c3 * w1 + c4 * w0;\n}\n\nfn sampleWithBlurAndConvergence(uv: vec2f, convergence: vec3f, blurSize: f32, texelSize: vec2f) -> vec3f {\n if (blurSize <= 0.0) {\n \n let offsetR = vec2f(convergence.r * texelSize.x, 0.0);\n let offsetG = vec2f(convergence.g * texelSize.x, 0.0);\n let offsetB = vec2f(convergence.b * texelSize.x, 0.0);\n let r = textureSampleLevel(inputTexture, inputSampler, uv + offsetR, 0.0).r;\n let g = textureSampleLevel(inputTexture, inputSampler, uv + offsetG, 0.0).g;\n let b = textureSampleLevel(inputTexture, inputSampler, uv + offsetB, 0.0).b;\n return vec3f(r, g, b);\n }\n\n \n let w0 = 0.0625;\n let w1 = 0.25;\n let w2 = 0.375;\n let offset = blurSize * texelSize.x;\n\n \n var r = 0.0;\n var g = 0.0;\n var b = 0.0;\n\n for (var i = -2; i <= 2; i++) {\n let weight = select(select(w1, w2, i == 0), w0, abs(i) == 2);\n let blurOffset = f32(i) * offset;\n\n r += textureSampleLevel(inputTexture, inputSampler, uv + vec2f(convergence.r * texelSize.x + blurOffset, 0.0), 0.0).r * weight;\n g += textureSampleLevel(inputTexture, inputSampler, uv + vec2f(convergence.g * texelSize.x + blurOffset, 0.0), 0.0).g * weight;\n b += textureSampleLevel(inputTexture, inputSampler, uv + vec2f(convergence.b * texelSize.x + blurOffset, 0.0), 0.0).b * weight;\n }\n\n return vec3f(r, g, b);\n}\n\n@vertex\nfn vertexMain(@builtin(vertex_index) vertexIndex: u32) -> VertexOutput {\n var output: VertexOutput;\n\n \n let x = f32(vertexIndex & 1u) * 4.0 - 1.0;\n let y = f32(vertexIndex >> 1u) * 4.0 - 1.0;\n output.position = vec4f(x, y, 0.0, 1.0);\n output.uv = vec2f((x + 1.0) * 0.5, (1.0 - y) * 0.5);\n\n return output;\n}\n\n@fragment\nfn fragmentMain(input: VertexOutput) -> @location(0) vec4f {\n \n let pixelPos = input.position.xy;\n var uv = input.uv;\n\n \n let crtEnabled = uniforms.crtEnabled > 0.5;\n\n \n \n if (!crtEnabled && uniforms.upscaleEnabled < 0.5) {\n return textureSampleLevel(inputTexture, nearestSampler, uv, 0.0);\n }\n\n \n var distortedUV = uv;\n var cornerMask = 1.0;\n\n if (crtEnabled && uniforms.curvature > 0.0) {\n \n var zoomedUV = uv;\n if (uniforms.zoom > 1.0) {\n let centered = uv - 0.5;\n zoomedUV = centered / uniforms.zoom + 0.5;\n }\n distortedUV = applyBarrelDistortion(zoomedUV, uniforms.curvature);\n cornerMask = getCornerMask(distortedUV, uniforms.cornerRadius);\n\n \n \n if (distortedUV.x < 0.0 || distortedUV.x > 1.0 ||\n distortedUV.y < 0.0 || distortedUV.y > 1.0) {\n return vec4f(0.0, 0.0, 0.0, 1.0);\n }\n }\n\n \n let texelSize = 1.0 / uniforms.inputSize;\n\n \n var color: vec3f;\n if (crtEnabled) {\n let hasConvergence = any(uniforms.convergence != vec3f(0.0));\n if (hasConvergence) {\n \n color = sampleWithBlurAndConvergence(distortedUV, uniforms.convergence, uniforms.blurSize, texelSize);\n } else {\n \n color = sampleWithHorizontalBlur(distortedUV, uniforms.blurSize, texelSize);\n }\n } else {\n \n \n let hasUpscaling = uniforms.inputSize.x > uniforms.canvasSize.x + 0.5 ||\n uniforms.inputSize.y > uniforms.canvasSize.y + 0.5;\n\n if (hasUpscaling) {\n \n color = textureSampleLevel(inputTexture, inputSampler, distortedUV, 0.0).rgb;\n } else {\n \n \n color = textureSampleLevel(inputTexture, nearestSampler, distortedUV, 0.0).rgb;\n }\n }\n\n \n if (crtEnabled) {\n \n \n let scanlinePos = pixelPos.y / max(uniforms.scanlineHeight, 1.0);\n\n \n color = applyScanlines(\n color,\n scanlinePos,\n uniforms.scanlineIntensity,\n uniforms.scanlineWidth,\n uniforms.scanlineBrightBoost\n );\n\n \n if (uniforms.maskType > 0.5 && uniforms.maskIntensity > 0.0) {\n var mask: vec3f;\n let maskPos = pixelPos / uniforms.maskScale;\n if (uniforms.maskType < 1.5) {\n \n mask = proceduralApertureGrille(maskPos.x, 1.0);\n } else if (uniforms.maskType < 2.5) {\n \n mask = proceduralSlotMask(maskPos, 1.0, uniforms.scanlineHeight / uniforms.maskScale);\n } else {\n \n mask = proceduralShadowMask(maskPos, 1.0);\n }\n\n \n let maskedColor = color * mask;\n color = mix(color, maskedColor, uniforms.maskIntensity);\n color *= uniforms.maskCompensation;\n }\n\n \n color = applyVignette(color, uv, uniforms.vignetteIntensity, uniforms.vignetteSize);\n\n \n color *= cornerMask;\n }\n\n return vec4f(color, 1.0);\n}";
12271
+ class CRTPass extends BasePass {
12272
+ constructor(engine = null) {
12273
+ super("CRT", engine);
12274
+ this.pipeline = null;
12275
+ this.inputTexture = null;
12276
+ this.upscaledTexture = null;
12277
+ this.upscaledWidth = 0;
12278
+ this.upscaledHeight = 0;
12279
+ this.linearSampler = null;
12280
+ this.nearestSampler = null;
12281
+ this.phosphorMaskTexture = null;
12282
+ this.canvasWidth = 0;
12283
+ this.canvasHeight = 0;
12284
+ this.renderWidth = 0;
12285
+ this.renderHeight = 0;
12286
+ }
12287
+ // Settings getters
12288
+ get crtSettings() {
12289
+ return this.engine?.settings?.crt ?? {};
12290
+ }
12291
+ get crtEnabled() {
12292
+ return this.crtSettings.enabled ?? false;
12293
+ }
12294
+ get upscaleEnabled() {
12295
+ return this.crtSettings.upscaleEnabled ?? false;
12296
+ }
12297
+ get upscaleTarget() {
12298
+ return this.crtSettings.upscaleTarget ?? 4;
12299
+ }
12300
+ get maxTextureSize() {
12301
+ return this.crtSettings.maxTextureSize ?? 4096;
12302
+ }
12303
+ // Geometry
12304
+ get curvature() {
12305
+ return this.crtSettings.curvature ?? 0.03;
12306
+ }
12307
+ get cornerRadius() {
12308
+ return this.crtSettings.cornerRadius ?? 0.03;
12309
+ }
12310
+ get zoom() {
12311
+ return this.crtSettings.zoom ?? 1;
12312
+ }
12313
+ // Scanlines
12314
+ get scanlineIntensity() {
12315
+ return this.crtSettings.scanlineIntensity ?? 0.25;
12316
+ }
12317
+ get scanlineWidth() {
12318
+ return this.crtSettings.scanlineWidth ?? 0.5;
12319
+ }
12320
+ get scanlineBrightBoost() {
12321
+ return this.crtSettings.scanlineBrightBoost ?? 1;
12322
+ }
12323
+ get scanlineHeight() {
12324
+ return this.crtSettings.scanlineHeight ?? 3;
12325
+ }
12326
+ // pixels per scanline
12327
+ // Convergence
12328
+ get convergence() {
12329
+ return this.crtSettings.convergence ?? [0.5, 0, -0.5];
12330
+ }
12331
+ // Phosphor mask
12332
+ get maskType() {
12333
+ const type = this.crtSettings.maskType ?? "aperture";
12334
+ switch (type) {
12335
+ case "none":
12336
+ return 0;
12337
+ case "aperture":
12338
+ return 1;
12339
+ case "slot":
12340
+ return 2;
12341
+ case "shadow":
12342
+ return 3;
12343
+ default:
12344
+ return 1;
12345
+ }
12346
+ }
12347
+ get maskIntensity() {
12348
+ return this.crtSettings.maskIntensity ?? 0.15;
12349
+ }
12350
+ get maskScale() {
12351
+ return this.crtSettings.maskScale ?? 1;
12352
+ }
12353
+ // Vignette
12354
+ get vignetteIntensity() {
12355
+ return this.crtSettings.vignetteIntensity ?? 0.15;
12356
+ }
12357
+ get vignetteSize() {
12358
+ return this.crtSettings.vignetteSize ?? 0.4;
12359
+ }
12360
+ // Blur
12361
+ get blurSize() {
12362
+ return this.crtSettings.blurSize ?? 0.5;
12363
+ }
12364
+ /**
12365
+ * Calculate brightness compensation for phosphor mask
12366
+ * Pre-computed on CPU to avoid per-pixel calculation in shader
12367
+ * @param {number} maskType - 0=none, 1=aperture, 2=slot, 3=shadow
12368
+ * @param {number} intensity - mask intensity 0-1
12369
+ * @returns {number} compensation multiplier
12370
+ */
12371
+ _calculateMaskCompensation(maskType, intensity) {
12372
+ if (maskType < 0.5 || intensity <= 0) {
12373
+ return 1;
12374
+ }
12375
+ let darkening;
12376
+ let useLinearOnly = false;
12377
+ if (maskType < 1.5) {
12378
+ darkening = 0.25;
12379
+ } else if (maskType < 2.5) {
12380
+ darkening = 0.27;
12381
+ } else {
12382
+ darkening = 0.82;
12383
+ useLinearOnly = true;
12384
+ }
12385
+ const linearComp = 1 / Math.max(1 - intensity * darkening, 0.1);
12386
+ if (useLinearOnly) {
12387
+ return linearComp;
12388
+ }
12389
+ const expComp = Math.exp(intensity * darkening);
12390
+ const t = Math.max(0, Math.min(1, (intensity - 0.4) / 0.2));
12391
+ const blendFactor = t * t * (3 - 2 * t);
12392
+ return linearComp * (1 - blendFactor) + expComp * blendFactor;
12393
+ }
12394
+ /**
12395
+ * Set the input texture (from PostProcessPass intermediate output)
12396
+ * @param {Object} texture - Input texture object
12397
+ */
12398
+ setInputTexture(texture) {
12399
+ if (this.inputTexture !== texture) {
12400
+ this.inputTexture = texture;
12401
+ this._needsRebuild = true;
12402
+ this._blitPipeline = null;
12403
+ }
12404
+ }
12405
+ /**
12406
+ * Set the render size (before upscaling)
12407
+ * @param {number} width - Render width
12408
+ * @param {number} height - Render height
12409
+ */
12410
+ setRenderSize(width, height) {
12411
+ if (this.renderWidth !== width || this.renderHeight !== height) {
12412
+ this.renderWidth = width;
12413
+ this.renderHeight = height;
12414
+ this._needsUpscaleRebuild = true;
12415
+ }
12416
+ }
12417
+ /**
12418
+ * Calculate the upscaled texture size
12419
+ * @returns {{width: number, height: number, scale: number}}
12420
+ */
12421
+ _calculateUpscaledSize() {
12422
+ const renderW = this.renderWidth || this.canvasWidth;
12423
+ const renderH = this.renderHeight || this.canvasHeight;
12424
+ const maxSize = this.maxTextureSize;
12425
+ let scale = this.upscaleTarget;
12426
+ while (scale > 1 && (renderW * scale > maxSize || renderH * scale > maxSize)) {
12427
+ scale--;
12428
+ }
12429
+ const maxCanvasScale = 2;
12430
+ while (scale > 1 && (renderW * scale > this.canvasWidth * maxCanvasScale || renderH * scale > this.canvasHeight * maxCanvasScale)) {
12431
+ scale--;
12432
+ }
12433
+ scale = Math.max(scale, 1);
12434
+ const targetW = renderW * scale;
12435
+ const targetH = renderH * scale;
12436
+ return { width: targetW, height: targetH, scale };
12437
+ }
12438
+ /**
12439
+ * Check if actual upscaling is needed
12440
+ * Returns true only if the upscaled texture would be larger than the input
12441
+ */
12442
+ _needsUpscaling() {
12443
+ if (!this.upscaleEnabled) return false;
12444
+ const { scale } = this._calculateUpscaledSize();
12445
+ return scale > 1;
12446
+ }
12447
+ async _init() {
12448
+ const { device } = this.engine;
12449
+ this.linearSampler = device.createSampler({
12450
+ label: "CRT Linear Sampler",
12451
+ minFilter: "linear",
12452
+ magFilter: "linear",
12453
+ addressModeU: "mirror-repeat",
12454
+ addressModeV: "mirror-repeat"
11188
12455
  });
11189
- this._needsRebuild = false;
12456
+ this.nearestSampler = device.createSampler({
12457
+ label: "CRT Nearest Sampler",
12458
+ minFilter: "nearest",
12459
+ magFilter: "nearest",
12460
+ addressModeU: "mirror-repeat",
12461
+ addressModeV: "mirror-repeat"
12462
+ });
12463
+ await this._createPhosphorMaskTexture();
11190
12464
  }
11191
- async _execute(context) {
12465
+ /**
12466
+ * Create phosphor mask texture
12467
+ * This is a simple procedural texture for the aperture grille pattern
12468
+ */
12469
+ async _createPhosphorMaskTexture() {
11192
12470
  const { device } = this.engine;
11193
- if (this.guiCanvas && this.guiCanvas.width > 0 && this.guiCanvas.height > 0) {
11194
- const needsNewTexture = !this.guiTexture || this.guiTexture.width !== this.guiCanvas.width || this.guiTexture.height !== this.guiCanvas.height;
11195
- if (needsNewTexture) {
11196
- if (this.guiTexture?.texture) {
11197
- this.guiTexture.texture.destroy();
12471
+ const size = 6;
12472
+ const data = new Uint8Array(size * 2 * 4);
12473
+ for (let y = 0; y < 2; y++) {
12474
+ for (let x = 0; x < size; x++) {
12475
+ const idx = (y * size + x) * 4;
12476
+ const phase = x % 3;
12477
+ if (phase === 0) {
12478
+ data[idx] = 255;
12479
+ data[idx + 1] = 50;
12480
+ data[idx + 2] = 50;
12481
+ } else if (phase === 1) {
12482
+ data[idx] = 50;
12483
+ data[idx + 1] = 255;
12484
+ data[idx + 2] = 50;
12485
+ } else {
12486
+ data[idx] = 50;
12487
+ data[idx + 1] = 50;
12488
+ data[idx + 2] = 255;
11198
12489
  }
11199
- const texture = device.createTexture({
11200
- label: "GUI Texture",
11201
- size: [this.guiCanvas.width, this.guiCanvas.height, 1],
11202
- format: "rgba8unorm",
11203
- usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST | GPUTextureUsage.RENDER_ATTACHMENT
11204
- });
11205
- this.guiTexture = {
11206
- texture,
11207
- view: texture.createView(),
11208
- sampler: this.guiSampler,
11209
- width: this.guiCanvas.width,
11210
- height: this.guiCanvas.height
11211
- };
11212
- this._needsRebuild = true;
12490
+ data[idx + 3] = 255;
11213
12491
  }
11214
- device.queue.copyExternalImageToTexture(
11215
- { source: this.guiCanvas },
11216
- { texture: this.guiTexture.texture },
11217
- [this.guiCanvas.width, this.guiCanvas.height]
11218
- );
11219
12492
  }
11220
- if (this._needsRebuild) {
11221
- await this._buildPipeline();
12493
+ const texture = device.createTexture({
12494
+ label: "Phosphor Mask",
12495
+ size: [size, 2, 1],
12496
+ format: "rgba8unorm",
12497
+ usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST
12498
+ });
12499
+ device.queue.writeTexture(
12500
+ { texture },
12501
+ data,
12502
+ { bytesPerRow: size * 4 },
12503
+ { width: size, height: 2 }
12504
+ );
12505
+ const sampler = device.createSampler({
12506
+ label: "Phosphor Mask Sampler",
12507
+ minFilter: "nearest",
12508
+ magFilter: "nearest",
12509
+ addressModeU: "repeat",
12510
+ addressModeV: "repeat"
12511
+ });
12512
+ this.phosphorMaskTexture = {
12513
+ texture,
12514
+ view: texture.createView(),
12515
+ sampler
12516
+ };
12517
+ }
12518
+ /**
12519
+ * Create or resize the upscaled texture
12520
+ */
12521
+ async _createUpscaledTexture() {
12522
+ const { device } = this.engine;
12523
+ const { width, height, scale } = this._calculateUpscaledSize();
12524
+ if (this.upscaledWidth === width && this.upscaledHeight === height) {
12525
+ return;
11222
12526
  }
11223
- if (!this.pipeline || this._needsRebuild) {
11224
- console.warn("PostProcessPass: Pipeline not ready");
12527
+ if (this.upscaledTexture?.texture) {
12528
+ this.upscaledTexture.texture.destroy();
12529
+ }
12530
+ const texture = device.createTexture({
12531
+ label: "CRT Upscaled Texture",
12532
+ size: [width, height, 1],
12533
+ format: "rgba8unorm",
12534
+ usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.COPY_DST
12535
+ });
12536
+ this.upscaledTexture = {
12537
+ texture,
12538
+ view: texture.createView(),
12539
+ width,
12540
+ height,
12541
+ scale,
12542
+ format: "rgba8unorm"
12543
+ };
12544
+ this.upscaledWidth = width;
12545
+ this.upscaledHeight = height;
12546
+ console.log(`CRTPass: Created upscaled texture ${width}x${height} (${scale.toFixed(1)}x)`);
12547
+ this._needsRebuild = true;
12548
+ this._blitPipeline = null;
12549
+ }
12550
+ /**
12551
+ * Build or rebuild the CRT pipeline
12552
+ */
12553
+ async _buildPipeline() {
12554
+ if (!this.inputTexture && !this.upscaledTexture) {
11225
12555
  return;
11226
12556
  }
11227
- const hasBloom = this.bloomTexture && this.bloomEnabled;
11228
- const effectiveBloomTexture = this.bloomTexture || this.dummyBloomTexture;
11229
- this.pipeline.uniformValues.set({
11230
- noiseParams: [this.noiseSize, this.noiseAnimated ? Math.random() : 0, this.noiseAnimated ? Math.random() : 0, this.fxaa ? 1 : 0],
11231
- ditherParams: [this.ditheringEnabled ? 1 : 0, this.colorLevels, 0, 0],
11232
- bloomParams: [hasBloom ? 1 : 0, this.bloomIntensity, this.bloomRadius, effectiveBloomTexture?.mipCount ?? 1]
12557
+ const { device } = this.engine;
12558
+ const needsUpscaling = this._needsUpscaling();
12559
+ const effectiveInput = (this.crtEnabled || this.upscaleEnabled) && needsUpscaling && this.upscaledTexture ? this.upscaledTexture : this.inputTexture;
12560
+ if (!effectiveInput) return;
12561
+ const bindGroupLayout = device.createBindGroupLayout({
12562
+ label: "CRT Bind Group Layout",
12563
+ entries: [
12564
+ { binding: 0, visibility: GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } },
12565
+ { binding: 1, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: "float" } },
12566
+ { binding: 2, visibility: GPUShaderStage.FRAGMENT, sampler: { type: "filtering" } },
12567
+ { binding: 3, visibility: GPUShaderStage.FRAGMENT, sampler: { type: "filtering" } },
12568
+ { binding: 4, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: "float" } },
12569
+ { binding: 5, visibility: GPUShaderStage.FRAGMENT, sampler: { type: "filtering" } }
12570
+ ]
11233
12571
  });
11234
- this.pipeline.render();
12572
+ const uniformBuffer = device.createBuffer({
12573
+ label: "CRT Uniforms",
12574
+ size: 128,
12575
+ // Padded for alignment
12576
+ usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST
12577
+ });
12578
+ const bindGroup = device.createBindGroup({
12579
+ label: "CRT Bind Group",
12580
+ layout: bindGroupLayout,
12581
+ entries: [
12582
+ { binding: 0, resource: { buffer: uniformBuffer } },
12583
+ { binding: 1, resource: effectiveInput.view },
12584
+ { binding: 2, resource: this.linearSampler },
12585
+ { binding: 3, resource: this.nearestSampler },
12586
+ { binding: 4, resource: this.phosphorMaskTexture.view },
12587
+ { binding: 5, resource: this.phosphorMaskTexture.sampler }
12588
+ ]
12589
+ });
12590
+ const pipelineLayout = device.createPipelineLayout({
12591
+ label: "CRT Pipeline Layout",
12592
+ bindGroupLayouts: [bindGroupLayout]
12593
+ });
12594
+ const shaderModule = device.createShaderModule({
12595
+ label: "CRT Shader",
12596
+ code: crt_default
12597
+ });
12598
+ const pipeline = device.createRenderPipeline({
12599
+ label: "CRT Pipeline",
12600
+ layout: pipelineLayout,
12601
+ vertex: {
12602
+ module: shaderModule,
12603
+ entryPoint: "vertexMain"
12604
+ },
12605
+ fragment: {
12606
+ module: shaderModule,
12607
+ entryPoint: "fragmentMain",
12608
+ targets: [{
12609
+ format: navigator.gpu.getPreferredCanvasFormat()
12610
+ }]
12611
+ },
12612
+ primitive: {
12613
+ topology: "triangle-list"
12614
+ }
12615
+ });
12616
+ this.pipeline = {
12617
+ pipeline,
12618
+ bindGroup,
12619
+ uniformBuffer
12620
+ };
12621
+ this._pipelineInputTexture = this.inputTexture;
12622
+ this._pipelineUpscaledTexture = needsUpscaling ? this.upscaledTexture : null;
12623
+ this._needsRebuild = false;
12624
+ }
12625
+ /**
12626
+ * Upscale the input texture using nearest-neighbor filtering
12627
+ */
12628
+ _upscaleInput() {
12629
+ if (!this.inputTexture || !this.upscaledTexture) return;
12630
+ const { device } = this.engine;
12631
+ const commandEncoder = device.createCommandEncoder({ label: "CRT Upscale" });
12632
+ if (this.inputTexture.width === this.upscaledTexture.width && this.inputTexture.height === this.upscaledTexture.height) {
12633
+ commandEncoder.copyTextureToTexture(
12634
+ { texture: this.inputTexture.texture },
12635
+ { texture: this.upscaledTexture.texture },
12636
+ [this.inputTexture.width, this.inputTexture.height]
12637
+ );
12638
+ } else {
12639
+ if (!this._blitPipeline || this._blitInputTexture !== this.inputTexture) {
12640
+ this._createBlitPipeline();
12641
+ }
12642
+ if (this._blitPipeline) {
12643
+ const renderPass = commandEncoder.beginRenderPass({
12644
+ colorAttachments: [{
12645
+ view: this.upscaledTexture.view,
12646
+ loadOp: "clear",
12647
+ storeOp: "store",
12648
+ clearValue: { r: 0, g: 0, b: 0, a: 1 }
12649
+ }]
12650
+ });
12651
+ renderPass.setPipeline(this._blitPipeline.pipeline);
12652
+ renderPass.setBindGroup(0, this._blitPipeline.bindGroup);
12653
+ renderPass.draw(3, 1, 0, 0);
12654
+ renderPass.end();
12655
+ }
12656
+ }
12657
+ device.queue.submit([commandEncoder.finish()]);
12658
+ }
12659
+ /**
12660
+ * Create a simple nearest-neighbor blit pipeline
12661
+ */
12662
+ _createBlitPipeline() {
12663
+ if (!this.inputTexture) return;
12664
+ const { device } = this.engine;
12665
+ this._blitInputTexture = this.inputTexture;
12666
+ const blitShader = `
12667
+ struct VertexOutput {
12668
+ @builtin(position) position: vec4f,
12669
+ @location(0) uv: vec2f,
12670
+ }
12671
+
12672
+ @group(0) @binding(0) var inputTexture: texture_2d<f32>;
12673
+ @group(0) @binding(1) var inputSampler: sampler;
12674
+
12675
+ @vertex
12676
+ fn vertexMain(@builtin(vertex_index) idx: u32) -> VertexOutput {
12677
+ var output: VertexOutput;
12678
+ let x = f32(idx & 1u) * 4.0 - 1.0;
12679
+ let y = f32(idx >> 1u) * 4.0 - 1.0;
12680
+ output.position = vec4f(x, y, 0.0, 1.0);
12681
+ output.uv = vec2f((x + 1.0) * 0.5, (1.0 - y) * 0.5);
12682
+ return output;
12683
+ }
12684
+
12685
+ @fragment
12686
+ fn fragmentMain(input: VertexOutput) -> @location(0) vec4f {
12687
+ return textureSample(inputTexture, inputSampler, input.uv);
12688
+ }
12689
+ `;
12690
+ const shaderModule = device.createShaderModule({
12691
+ label: "Blit Shader",
12692
+ code: blitShader
12693
+ });
12694
+ const bindGroupLayout = device.createBindGroupLayout({
12695
+ label: "Blit Bind Group Layout",
12696
+ entries: [
12697
+ { binding: 0, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: "float" } },
12698
+ { binding: 1, visibility: GPUShaderStage.FRAGMENT, sampler: { type: "filtering" } }
12699
+ ]
12700
+ });
12701
+ const bindGroup = device.createBindGroup({
12702
+ label: "Blit Bind Group",
12703
+ layout: bindGroupLayout,
12704
+ entries: [
12705
+ { binding: 0, resource: this.inputTexture.view },
12706
+ { binding: 1, resource: this.nearestSampler }
12707
+ ]
12708
+ });
12709
+ const pipeline = device.createRenderPipeline({
12710
+ label: "Blit Pipeline",
12711
+ layout: device.createPipelineLayout({ bindGroupLayouts: [bindGroupLayout] }),
12712
+ vertex: {
12713
+ module: shaderModule,
12714
+ entryPoint: "vertexMain"
12715
+ },
12716
+ fragment: {
12717
+ module: shaderModule,
12718
+ entryPoint: "fragmentMain",
12719
+ targets: [{ format: "rgba8unorm" }]
12720
+ },
12721
+ primitive: { topology: "triangle-list" }
12722
+ });
12723
+ this._blitPipeline = { pipeline, bindGroup };
12724
+ }
12725
+ async _execute(context) {
12726
+ const { device, canvas } = this.engine;
12727
+ const needsUpscaling = this._needsUpscaling();
12728
+ const effectiveUpscaledTexture = needsUpscaling ? this.upscaledTexture : null;
12729
+ if (this.pipeline && (this._pipelineInputTexture !== this.inputTexture || this._pipelineUpscaledTexture !== effectiveUpscaledTexture)) {
12730
+ this._needsRebuild = true;
12731
+ this._blitPipeline = null;
12732
+ }
12733
+ if (!this.crtEnabled && !this.upscaleEnabled) {
12734
+ if (!this.inputTexture) return;
12735
+ if (this._needsRebuild || !this.pipeline) {
12736
+ await this._buildPipeline();
12737
+ }
12738
+ } else {
12739
+ if (needsUpscaling) {
12740
+ if (this._needsUpscaleRebuild) {
12741
+ await this._createUpscaledTexture();
12742
+ this._needsUpscaleRebuild = false;
12743
+ }
12744
+ if (this.inputTexture && this.upscaledTexture) {
12745
+ this._upscaleInput();
12746
+ }
12747
+ }
12748
+ if (this._needsRebuild || !this.pipeline) {
12749
+ await this._buildPipeline();
12750
+ }
12751
+ }
12752
+ if (!this.pipeline) return;
12753
+ const uniformData = new Float32Array(32);
12754
+ uniformData[0] = this.canvasWidth;
12755
+ uniformData[1] = this.canvasHeight;
12756
+ const inputW = needsUpscaling && this.upscaledTexture ? this.upscaledTexture.width : this.inputTexture?.width || this.canvasWidth;
12757
+ const inputH = needsUpscaling && this.upscaledTexture ? this.upscaledTexture.height : this.inputTexture?.height || this.canvasHeight;
12758
+ uniformData[2] = inputW;
12759
+ uniformData[3] = inputH;
12760
+ const renderW = this.renderWidth || this.canvasWidth;
12761
+ const renderH = this.renderHeight || this.canvasHeight;
12762
+ uniformData[4] = renderW;
12763
+ uniformData[5] = renderH;
12764
+ if (!this._loggedDimensions) {
12765
+ const renderScale = renderW > 0 ? (renderW / this.canvasWidth).toFixed(2) : "?";
12766
+ const upscaleInfo = needsUpscaling ? `upscale=${this.upscaledTexture?.scale || "?"}x` : "no-upscale (direct)";
12767
+ console.log(`CRT: canvas=${this.canvasWidth}x${this.canvasHeight}, render=${renderW}x${renderH} (scale ${renderScale}), input=${inputW}x${inputH}, ${upscaleInfo}`);
12768
+ console.log(`CRT: Scanlines use fragment coords (${this.canvasHeight}px), should repeat every ${this.scanlineHeight}px = ${Math.floor(this.canvasHeight / this.scanlineHeight)} scanlines`);
12769
+ this._loggedDimensions = true;
12770
+ }
12771
+ uniformData[6] = this.curvature;
12772
+ uniformData[7] = this.cornerRadius;
12773
+ uniformData[8] = this.zoom;
12774
+ uniformData[9] = 0;
12775
+ uniformData[10] = this.scanlineIntensity;
12776
+ uniformData[11] = this.scanlineWidth;
12777
+ uniformData[12] = this.scanlineBrightBoost;
12778
+ uniformData[13] = this.scanlineHeight;
12779
+ uniformData[14] = 0;
12780
+ uniformData[15] = 0;
12781
+ const conv = this.convergence;
12782
+ uniformData[16] = conv[0];
12783
+ uniformData[17] = conv[1];
12784
+ uniformData[18] = conv[2];
12785
+ uniformData[19] = 0;
12786
+ uniformData[20] = this.maskType;
12787
+ uniformData[21] = this.maskIntensity;
12788
+ uniformData[22] = this.maskScale;
12789
+ const maskCompensation = this._calculateMaskCompensation(this.maskType, this.maskIntensity);
12790
+ uniformData[23] = maskCompensation;
12791
+ uniformData[24] = this.vignetteIntensity;
12792
+ uniformData[25] = this.vignetteSize;
12793
+ uniformData[26] = this.blurSize;
12794
+ uniformData[27] = this.crtEnabled ? 1 : 0;
12795
+ uniformData[28] = this.upscaleEnabled ? 1 : 0;
12796
+ device.queue.writeBuffer(this.pipeline.uniformBuffer, 0, uniformData);
12797
+ const commandEncoder = device.createCommandEncoder({ label: "CRT Render" });
12798
+ const canvasTexture = this.engine.context.getCurrentTexture();
12799
+ const renderPass = commandEncoder.beginRenderPass({
12800
+ colorAttachments: [{
12801
+ view: canvasTexture.createView(),
12802
+ loadOp: "clear",
12803
+ storeOp: "store",
12804
+ clearValue: { r: 0, g: 0, b: 0, a: 1 }
12805
+ }]
12806
+ });
12807
+ renderPass.setPipeline(this.pipeline.pipeline);
12808
+ renderPass.setBindGroup(0, this.pipeline.bindGroup);
12809
+ renderPass.draw(3, 1, 0, 0);
12810
+ renderPass.end();
12811
+ device.queue.submit([commandEncoder.finish()]);
11235
12812
  }
11236
12813
  async _resize(width, height) {
12814
+ this.canvasWidth = width;
12815
+ this.canvasHeight = height;
12816
+ this._needsUpscaleRebuild = true;
11237
12817
  this._needsRebuild = true;
12818
+ this._loggedDimensions = false;
11238
12819
  }
11239
12820
  _destroy() {
11240
- this.pipeline = null;
11241
- if (this.dummyBloomTexture?.texture) {
11242
- this.dummyBloomTexture.texture.destroy();
11243
- this.dummyBloomTexture = null;
12821
+ if (this.pipeline?.uniformBuffer) {
12822
+ this.pipeline.uniformBuffer.destroy();
11244
12823
  }
11245
- if (this.guiTexture?.texture) {
11246
- this.guiTexture.texture.destroy();
11247
- this.guiTexture = null;
12824
+ if (this.upscaledTexture?.texture) {
12825
+ this.upscaledTexture.texture.destroy();
11248
12826
  }
11249
- if (this.dummyGuiTexture?.texture) {
11250
- this.dummyGuiTexture.texture.destroy();
11251
- this.dummyGuiTexture = null;
12827
+ if (this.phosphorMaskTexture?.texture) {
12828
+ this.phosphorMaskTexture.texture.destroy();
11252
12829
  }
12830
+ this.pipeline = null;
12831
+ this._blitPipeline = null;
11253
12832
  }
11254
12833
  }
11255
12834
  class AmbientCapturePass extends BasePass {
@@ -13413,8 +14992,10 @@ class RenderGraph {
13413
14992
  // Pass 11: Forward transparent objects
13414
14993
  particles: null,
13415
14994
  // Pass 12: GPU particle rendering
13416
- postProcess: null
14995
+ postProcess: null,
13417
14996
  // Pass 13: Tone mapping + bloom composite
14997
+ crt: null
14998
+ // Pass 14: CRT effect (optional)
13418
14999
  };
13419
15000
  this.historyManager = null;
13420
15001
  this.cullingSystem = new CullingSystem(engine);
@@ -13487,7 +15068,9 @@ class RenderGraph {
13487
15068
  this.passes.transparent = new TransparentPass(this.engine);
13488
15069
  this.passes.particles = new ParticlePass(this.engine);
13489
15070
  this.passes.fog = new FogPass(this.engine);
15071
+ this.passes.volumetricFog = new VolumetricFogPass(this.engine);
13490
15072
  this.passes.postProcess = new PostProcessPass(this.engine);
15073
+ this.passes.crt = new CRTPass(this.engine);
13491
15074
  const { canvas } = this.engine;
13492
15075
  this.historyManager = new HistoryBufferManager(this.engine);
13493
15076
  start = performance.now();
@@ -13539,9 +15122,15 @@ class RenderGraph {
13539
15122
  await this.passes.fog.initialize();
13540
15123
  timings.push({ name: "init:fog", time: performance.now() - start });
13541
15124
  start = performance.now();
15125
+ await this.passes.volumetricFog.initialize();
15126
+ timings.push({ name: "init:volumetricFog", time: performance.now() - start });
15127
+ start = performance.now();
13542
15128
  await this.passes.postProcess.initialize();
13543
15129
  timings.push({ name: "init:postProcess", time: performance.now() - start });
13544
15130
  start = performance.now();
15131
+ await this.passes.crt.initialize();
15132
+ timings.push({ name: "init:crt", time: performance.now() - start });
15133
+ start = performance.now();
13545
15134
  this.passes.reflection.setFallbackEnvironment(environmentMap, this.environmentEncoding);
13546
15135
  this.passes.lighting.setEnvironmentMap(environmentMap, this.environmentEncoding);
13547
15136
  await this.passes.lighting.setGBuffer(this.passes.gbuffer.getGBuffer());
@@ -13605,11 +15194,16 @@ class RenderGraph {
13605
15194
  this.passes.particles.setShadowPass(this.passes.shadow);
13606
15195
  this.passes.particles.setEnvironmentMap(environmentMap, this.environmentEncoding);
13607
15196
  this.passes.particles.setLightingPass(this.passes.lighting);
15197
+ this.passes.volumetricFog.setGBuffer(this.passes.gbuffer.getGBuffer());
15198
+ this.passes.volumetricFog.setShadowPass(this.passes.shadow);
15199
+ this.passes.volumetricFog.setLightingPass(this.passes.lighting);
15200
+ this.passes.volumetricFog.setHiZPass(this.passes.hiz);
13608
15201
  this.passes.postProcess.setInputTexture(this.passes.lighting.getOutputTexture());
13609
15202
  this.passes.postProcess.setNoise(this.noiseTexture, this.noiseSize, this.noiseAnimated);
13610
15203
  if (this.engine?.guiCanvas) {
13611
15204
  this.passes.postProcess.setGuiCanvas(this.engine.guiCanvas);
13612
15205
  }
15206
+ this.invalidateOcclusionCulling();
13613
15207
  }
13614
15208
  /**
13615
15209
  * Render a frame using the new entity/asset system
@@ -13862,10 +15456,6 @@ class RenderGraph {
13862
15456
  this.passes.transparent.distanceFadeStart = transparentMaxDist * transparentFadeStart;
13863
15457
  await this.passes.transparent.execute(passContext);
13864
15458
  }
13865
- if (this.passes.particles && this.particleSystem.getActiveEmitters().length > 0) {
13866
- this.passes.particles.setOutputTexture(hdrSource);
13867
- await this.passes.particles.execute(passContext);
13868
- }
13869
15459
  const fogEnabled = this.engine?.settings?.environment?.fog?.enabled;
13870
15460
  if (this.passes.fog && fogEnabled) {
13871
15461
  this.passes.fog.setInputTexture(hdrSource);
@@ -13876,6 +15466,20 @@ class RenderGraph {
13876
15466
  hdrSource = fogOutput;
13877
15467
  }
13878
15468
  }
15469
+ if (this.passes.particles && this.particleSystem.getActiveEmitters().length > 0) {
15470
+ this.passes.particles.setOutputTexture(hdrSource);
15471
+ await this.passes.particles.execute(passContext);
15472
+ }
15473
+ const volumetricFogEnabled = this.engine?.settings?.volumetricFog?.enabled;
15474
+ if (this.passes.volumetricFog && volumetricFogEnabled) {
15475
+ this.passes.volumetricFog.setInputTexture(hdrSource);
15476
+ this.passes.volumetricFog.setGBuffer(gbuffer);
15477
+ await this.passes.volumetricFog.execute(passContext);
15478
+ const volFogOutput = this.passes.volumetricFog.getOutputTexture();
15479
+ if (volFogOutput && volFogOutput !== hdrSource) {
15480
+ hdrSource = volFogOutput;
15481
+ }
15482
+ }
13879
15483
  const bloomEnabled = this.engine?.settings?.bloom?.enabled;
13880
15484
  if (this.passes.bloom && bloomEnabled) {
13881
15485
  this.passes.bloom.setInputTexture(hdrSource);
@@ -13886,6 +15490,19 @@ class RenderGraph {
13886
15490
  }
13887
15491
  this.passes.postProcess.setInputTexture(hdrSource);
13888
15492
  await this.passes.postProcess.execute(passContext);
15493
+ const crtEnabled = this.engine?.settings?.crt?.enabled;
15494
+ const crtUpscaleEnabled = this.engine?.settings?.crt?.upscaleEnabled;
15495
+ if (crtEnabled || crtUpscaleEnabled) {
15496
+ const postProcessOutput = this.passes.postProcess.getOutputTexture();
15497
+ if (postProcessOutput) {
15498
+ this.passes.crt.setInputTexture(postProcessOutput);
15499
+ this.passes.crt.setRenderSize(
15500
+ this.passes.gbuffer.getGBuffer()?.depth?.width || canvas.width,
15501
+ this.passes.gbuffer.getGBuffer()?.depth?.height || canvas.height
15502
+ );
15503
+ await this.passes.crt.execute(passContext);
15504
+ }
15505
+ }
13889
15506
  this.historyManager.swap(camera);
13890
15507
  this._lastRenderContext = {
13891
15508
  meshes,
@@ -13950,6 +15567,40 @@ class RenderGraph {
13950
15567
  }
13951
15568
  }
13952
15569
  const asset = assetManager.get(modelId);
15570
+ if (asset?.meshNames && !asset.mesh) {
15571
+ for (const meshName of asset.meshNames) {
15572
+ const submeshId = assetManager.createModelId(modelId, meshName);
15573
+ const submeshAsset = assetManager.get(submeshId);
15574
+ if (!submeshAsset?.mesh) continue;
15575
+ if (submeshAsset.hasSkin && submeshAsset.skin) {
15576
+ for (const item of entities) {
15577
+ const entity = item.entity;
15578
+ const animation = entity.animation || "default";
15579
+ const phase = entity.phase || 0;
15580
+ const quantizedPhase = Math.floor(phase / 0.05) * 0.05;
15581
+ const key = `${submeshId}|${animation}|${quantizedPhase.toFixed(2)}`;
15582
+ if (!skinnedInstancedGroups.has(key)) {
15583
+ skinnedInstancedGroups.set(key, {
15584
+ modelId: submeshId,
15585
+ animation,
15586
+ phase: quantizedPhase,
15587
+ asset: submeshAsset,
15588
+ entities: []
15589
+ });
15590
+ }
15591
+ skinnedInstancedGroups.get(key).entities.push(item);
15592
+ }
15593
+ } else {
15594
+ if (!nonSkinnedGroups.has(submeshId)) {
15595
+ nonSkinnedGroups.set(submeshId, { asset: submeshAsset, entities: [] });
15596
+ }
15597
+ for (const item of entities) {
15598
+ nonSkinnedGroups.get(submeshId).entities.push(item);
15599
+ }
15600
+ }
15601
+ }
15602
+ continue;
15603
+ }
13953
15604
  if (!asset?.mesh) continue;
13954
15605
  if (asset.hasSkin && asset.skin) {
13955
15606
  for (const item of entities) {
@@ -14047,7 +15698,8 @@ class RenderGraph {
14047
15698
  geometry._instanceDataDirty = true;
14048
15699
  updatedMeshes.add(meshName);
14049
15700
  }
14050
- const globalTime = performance.now() / 1e3;
15701
+ const animationSpeed = this.engine?.settings?.animation?.speed ?? 1;
15702
+ const globalTime = performance.now() / 1e3 * animationSpeed;
14051
15703
  for (const { id: entityId, entity, asset, modelId } of skinnedIndividualEntities) {
14052
15704
  const entityAnimation = entity.animation || "default";
14053
15705
  const entityPhase = entity.phase || 0;
@@ -14090,7 +15742,9 @@ class RenderGraph {
14090
15742
  material: asset.mesh.material,
14091
15743
  skin: individualSkin2,
14092
15744
  hasSkin: true,
14093
- uid: `individual_${entityId}`
15745
+ uid: `individual_${entityId}`,
15746
+ // Use asset's combined bsphere for culling (all skinned submeshes share one sphere)
15747
+ combinedBsphere: asset.bsphere || null
14094
15748
  };
14095
15749
  cached = {
14096
15750
  skin: individualSkin2,
@@ -14219,7 +15873,9 @@ class RenderGraph {
14219
15873
  material: asset.mesh.material,
14220
15874
  skin: clonedSkin2,
14221
15875
  hasSkin: true,
14222
- uid: asset.mesh.uid + "_phase_" + key.replace(/[^a-zA-Z0-9]/g, "_")
15876
+ uid: asset.mesh.uid + "_phase_" + key.replace(/[^a-zA-Z0-9]/g, "_"),
15877
+ // Use asset's combined bsphere for culling (all skinned submeshes share one sphere)
15878
+ combinedBsphere: asset.bsphere || null
14223
15879
  };
14224
15880
  cached = { skin: clonedSkin2, mesh: phaseMesh2, geometry: phaseGeometry2 };
14225
15881
  this._skinnedPhaseCache.set(key, cached);
@@ -14354,45 +16010,62 @@ class RenderGraph {
14354
16010
  }
14355
16011
  /**
14356
16012
  * Handle window resize
14357
- * @param {number} width - New width
14358
- * @param {number} height - New height
16013
+ * @param {number} width - Canvas width (full device pixels)
16014
+ * @param {number} height - Canvas height (full device pixels)
16015
+ * @param {number} renderScale - Scale for internal rendering (1.0 = full resolution)
14359
16016
  */
14360
- async resize(width, height) {
16017
+ async resize(width, height, renderScale = 1) {
14361
16018
  const timings = [];
14362
16019
  performance.now();
16020
+ this.canvasWidth = width;
16021
+ this.canvasHeight = height;
16022
+ const renderWidth = Math.max(1, Math.round(width * renderScale));
16023
+ const renderHeight = Math.max(1, Math.round(height * renderScale));
16024
+ this.renderWidth = renderWidth;
16025
+ this.renderHeight = renderHeight;
16026
+ this.renderScale = renderScale;
14363
16027
  const autoScale = this.engine?.settings?.rendering?.autoScale;
14364
16028
  let effectScale = 1;
14365
16029
  if (autoScale && !autoScale.enabled && autoScale.enabledForEffects) {
14366
- if (height > (autoScale.maxHeight ?? 1536)) {
16030
+ if (renderHeight > (autoScale.maxHeight ?? 1536)) {
14367
16031
  effectScale = autoScale.scaleFactor ?? 0.5;
14368
16032
  if (!this._effectScaleWarned) {
14369
- console.log(`Effect auto-scale: Reducing effect resolution by ${effectScale} (height: ${height}px > ${autoScale.maxHeight}px)`);
16033
+ console.log(`Effect auto-scale: Reducing effect resolution by ${effectScale} (height: ${renderHeight}px > ${autoScale.maxHeight}px)`);
14370
16034
  this._effectScaleWarned = true;
14371
16035
  }
14372
16036
  } else if (this._effectScaleWarned) {
14373
- console.log(`Effect auto-scale: Restoring full effect resolution (height: ${height}px <= ${autoScale.maxHeight}px)`);
16037
+ console.log(`Effect auto-scale: Restoring full effect resolution (height: ${renderHeight}px <= ${autoScale.maxHeight}px)`);
14374
16038
  this._effectScaleWarned = false;
14375
16039
  }
14376
16040
  }
14377
- const scaledPasses = /* @__PURE__ */ new Set(["bloom", "ao", "ssgi", "planarReflection"]);
14378
- const effectWidth = Math.max(1, Math.floor(width * effectScale));
14379
- const effectHeight = Math.max(1, Math.floor(height * effectScale));
16041
+ const fullResPasses = /* @__PURE__ */ new Set(["crt"]);
16042
+ const effectScaledPasses = /* @__PURE__ */ new Set(["bloom", "ao", "planarReflection"]);
16043
+ const effectWidth = Math.max(1, Math.floor(renderWidth * effectScale));
16044
+ const effectHeight = Math.max(1, Math.floor(renderHeight * effectScale));
14380
16045
  this.effectWidth = effectWidth;
14381
16046
  this.effectHeight = effectHeight;
14382
16047
  this.effectScale = effectScale;
14383
16048
  for (const passName in this.passes) {
14384
16049
  if (this.passes[passName]) {
14385
16050
  const start2 = performance.now();
14386
- const useScaled = scaledPasses.has(passName) && effectScale < 1;
14387
- const w = useScaled ? effectWidth : width;
14388
- const h = useScaled ? effectHeight : height;
16051
+ let w, h;
16052
+ if (fullResPasses.has(passName)) {
16053
+ w = width;
16054
+ h = height;
16055
+ } else if (effectScaledPasses.has(passName) && effectScale < 1) {
16056
+ w = effectWidth;
16057
+ h = effectHeight;
16058
+ } else {
16059
+ w = renderWidth;
16060
+ h = renderHeight;
16061
+ }
14389
16062
  await this.passes[passName].resize(w, h);
14390
16063
  timings.push({ name: `pass:${passName}`, time: performance.now() - start2 });
14391
16064
  }
14392
16065
  }
14393
16066
  if (this.historyManager) {
14394
16067
  const start2 = performance.now();
14395
- await this.historyManager.resize(width, height);
16068
+ await this.historyManager.resize(renderWidth, renderHeight);
14396
16069
  timings.push({ name: "historyManager", time: performance.now() - start2 });
14397
16070
  }
14398
16071
  let start = performance.now();
@@ -14411,8 +16084,18 @@ class RenderGraph {
14411
16084
  this.passes.lighting.setHiZPass(this.passes.hiz);
14412
16085
  this.passes.transparent.setHiZPass(this.passes.hiz);
14413
16086
  this.passes.shadow.setHiZPass(this.passes.hiz);
16087
+ if (this.passes.volumetricFog) {
16088
+ this.passes.volumetricFog.setHiZPass(this.passes.hiz);
16089
+ }
14414
16090
  }
14415
16091
  timings.push({ name: "rewire:hiz", time: performance.now() - start });
16092
+ if (this.passes.volumetricFog) {
16093
+ start = performance.now();
16094
+ this.passes.volumetricFog.setGBuffer(this.passes.gbuffer.getGBuffer());
16095
+ this.passes.volumetricFog.setShadowPass(this.passes.shadow);
16096
+ this.passes.volumetricFog.setLightingPass(this.passes.lighting);
16097
+ timings.push({ name: "rewire:volumetricFog", time: performance.now() - start });
16098
+ }
14416
16099
  start = performance.now();
14417
16100
  this.passes.shadow.setNoise(this.noiseTexture, this.noiseSize, this.noiseAnimated);
14418
16101
  timings.push({ name: "rewire:shadow.setNoise", time: performance.now() - start });
@@ -14790,8 +16473,18 @@ class RenderGraph {
14790
16473
  * @param {string} passName - Name of pass
14791
16474
  * @returns {BasePass} The pass instance
14792
16475
  */
14793
- getPass(passName) {
14794
- return this.passes[passName];
16476
+ getPass(passName) {
16477
+ return this.passes[passName];
16478
+ }
16479
+ /**
16480
+ * Invalidate occlusion culling data and reset warmup period.
16481
+ * Call this after scene loading or major camera changes to prevent
16482
+ * incorrect occlusion culling with stale depth buffer data.
16483
+ */
16484
+ invalidateOcclusionCulling() {
16485
+ if (this.passes.hiz) {
16486
+ this.passes.hiz.invalidate();
16487
+ }
14795
16488
  }
14796
16489
  /**
14797
16490
  * Load noise texture based on settings
@@ -14929,6 +16622,38 @@ class RenderGraph {
14929
16622
  height: 8
14930
16623
  };
14931
16624
  }
16625
+ /**
16626
+ * Reload noise texture and update all passes that use it
16627
+ * Called when noise settings change at runtime
16628
+ */
16629
+ async reloadNoiseTexture() {
16630
+ if (this.noiseTexture?.texture) {
16631
+ this.noiseTexture.texture.destroy();
16632
+ }
16633
+ await this._loadNoiseTexture();
16634
+ if (this.passes.gbuffer) {
16635
+ this.passes.gbuffer.setNoise(this.noiseTexture, this.noiseSize, this.noiseAnimated);
16636
+ }
16637
+ if (this.passes.shadow) {
16638
+ this.passes.shadow.setNoise(this.noiseTexture, this.noiseSize, this.noiseAnimated);
16639
+ }
16640
+ if (this.passes.lighting) {
16641
+ this.passes.lighting.setNoise(this.noiseTexture, this.noiseSize, this.noiseAnimated);
16642
+ }
16643
+ if (this.passes.ao) {
16644
+ this.passes.ao.setNoise(this.noiseTexture, this.noiseSize, this.noiseAnimated);
16645
+ }
16646
+ if (this.passes.transparent) {
16647
+ this.passes.transparent.setNoise(this.noiseTexture, this.noiseSize, this.noiseAnimated);
16648
+ }
16649
+ if (this.passes.postProcess) {
16650
+ this.passes.postProcess.setNoise(this.noiseTexture, this.noiseSize, this.noiseAnimated);
16651
+ }
16652
+ if (this.passes.renderPost) {
16653
+ this.passes.renderPost.setNoise(this.noiseTexture, this.noiseSize, this.noiseAnimated);
16654
+ }
16655
+ console.log(`RenderGraph: Reloaded noise texture (${this.engine?.settings?.noise?.type || "bluenoise"})`);
16656
+ }
14932
16657
  /**
14933
16658
  * Destroy all resources
14934
16659
  */
@@ -15556,6 +17281,7 @@ class Joint {
15556
17281
  function expandTriangles(attributes, expansion) {
15557
17282
  const positions = attributes.position;
15558
17283
  const normals = attributes.normal;
17284
+ const tangents = attributes.tangent;
15559
17285
  const uvs = attributes.uv;
15560
17286
  const indices = attributes.indices;
15561
17287
  const weights = attributes.weights;
@@ -15564,6 +17290,7 @@ function expandTriangles(attributes, expansion) {
15564
17290
  const triCount = indices.length / 3;
15565
17291
  const newPositions = new Float32Array(triCount * 3 * 3);
15566
17292
  const newNormals = normals ? new Float32Array(triCount * 3 * 3) : null;
17293
+ const newTangents = tangents ? new Float32Array(triCount * 3 * 4) : null;
15567
17294
  const newUvs = uvs ? new Float32Array(triCount * 3 * 2) : null;
15568
17295
  const newWeights = weights ? new Float32Array(triCount * 3 * 4) : null;
15569
17296
  const newJoints = joints ? new Uint16Array(triCount * 3 * 4) : null;
@@ -15599,6 +17326,12 @@ function expandTriangles(attributes, expansion) {
15599
17326
  newNormals[newIdx * 3 + 1] = normals[origIdx * 3 + 1];
15600
17327
  newNormals[newIdx * 3 + 2] = normals[origIdx * 3 + 2];
15601
17328
  }
17329
+ if (newTangents) {
17330
+ newTangents[newIdx * 4 + 0] = tangents[origIdx * 4 + 0];
17331
+ newTangents[newIdx * 4 + 1] = tangents[origIdx * 4 + 1];
17332
+ newTangents[newIdx * 4 + 2] = tangents[origIdx * 4 + 2];
17333
+ newTangents[newIdx * 4 + 3] = tangents[origIdx * 4 + 3];
17334
+ }
15602
17335
  if (newUvs) {
15603
17336
  newUvs[newIdx * 2 + 0] = uvs[origIdx * 2 + 0];
15604
17337
  newUvs[newIdx * 2 + 1] = uvs[origIdx * 2 + 1];
@@ -15621,6 +17354,7 @@ function expandTriangles(attributes, expansion) {
15621
17354
  return {
15622
17355
  position: newPositions,
15623
17356
  normal: newNormals,
17357
+ tangent: newTangents,
15624
17358
  uv: newUvs,
15625
17359
  indices: newIndices,
15626
17360
  weights: newWeights,
@@ -15836,6 +17570,7 @@ async function loadGltfData(engine, url, options = {}) {
15836
17570
  let attrs = {
15837
17571
  position: getAccessor(attributes.POSITION),
15838
17572
  normal: getAccessor(attributes.NORMAL),
17573
+ tangent: getAccessor(attributes.TANGENT),
15839
17574
  uv: getAccessor(attributes.TEXCOORD_0),
15840
17575
  indices: getAccessor(primitive.indices),
15841
17576
  weights: getAccessor(attributes.WEIGHTS_0),
@@ -15954,6 +17689,9 @@ async function loadGltf(engine, url, options = {}) {
15954
17689
  const transmission = mesh.material.extensions.KHR_materials_transmission;
15955
17690
  material.opacity = 1 - (transmission.transmissionFactor ?? 0);
15956
17691
  }
17692
+ if (mesh.material.doubleSided) {
17693
+ material.doubleSided = true;
17694
+ }
15957
17695
  const nmesh = new Mesh(geometry, material, name);
15958
17696
  if (mesh.skinIndex !== void 0 && mesh.skinIndex !== null && skins[mesh.skinIndex]) {
15959
17697
  nmesh.skin = skins[mesh.skinIndex];
@@ -16463,6 +18201,22 @@ class AssetManager {
16463
18201
  try {
16464
18202
  const result = await loadGltf(this.engine, path, options);
16465
18203
  const meshNames = Object.keys(result.meshes);
18204
+ let combinedBsphere = null;
18205
+ const hasAnySkin = Object.values(result.meshes).some((m) => m.hasSkin);
18206
+ if (hasAnySkin) {
18207
+ const allPositions = [];
18208
+ for (const mesh of Object.values(result.meshes)) {
18209
+ if (mesh.geometry?.attributes?.position) {
18210
+ const positions = mesh.geometry.attributes.position;
18211
+ for (let i = 0; i < positions.length; i += 3) {
18212
+ allPositions.push(positions[i], positions[i + 1], positions[i + 2]);
18213
+ }
18214
+ }
18215
+ }
18216
+ if (allPositions.length > 0) {
18217
+ combinedBsphere = calculateBoundingSphere(new Float32Array(allPositions));
18218
+ }
18219
+ }
16466
18220
  this.assets[path] = {
16467
18221
  gltf: result,
16468
18222
  meshes: result.meshes,
@@ -16470,12 +18224,17 @@ class AssetManager {
16470
18224
  animations: result.animations,
16471
18225
  nodes: result.nodes,
16472
18226
  meshNames,
18227
+ bsphere: combinedBsphere,
18228
+ // Combined bsphere for parent path entities
18229
+ hasSkin: hasAnySkin,
18230
+ // Flag for skinned model detection
16473
18231
  ready: true,
16474
18232
  loading: false
16475
18233
  };
16476
18234
  for (const meshName of meshNames) {
16477
- const modelId = this.createModelId(path, meshName);
16478
- await this._registerMesh(path, meshName, result.meshes[meshName]);
18235
+ const mesh = result.meshes[meshName];
18236
+ const bsphere = hasAnySkin && combinedBsphere ? combinedBsphere : null;
18237
+ await this._registerMesh(path, meshName, mesh, bsphere);
16479
18238
  }
16480
18239
  this._triggerReady(path);
16481
18240
  return this.assets[path];
@@ -16491,10 +18250,14 @@ class AssetManager {
16491
18250
  }
16492
18251
  /**
16493
18252
  * Register a mesh asset (internal)
18253
+ * @param {string} path - GLTF file path
18254
+ * @param {string} meshName - Mesh name
18255
+ * @param {Object} mesh - Mesh object
18256
+ * @param {Object|null} overrideBsphere - Optional bounding sphere (for skinned mesh combined sphere)
16494
18257
  */
16495
- async _registerMesh(path, meshName, mesh) {
18258
+ async _registerMesh(path, meshName, mesh, overrideBsphere = null) {
16496
18259
  const modelId = this.createModelId(path, meshName);
16497
- const bsphere = calculateBoundingSphere(mesh.geometry.attributes.position);
18260
+ const bsphere = overrideBsphere || calculateBoundingSphere(mesh.geometry.attributes.position);
16498
18261
  this.assets[modelId] = {
16499
18262
  mesh,
16500
18263
  geometry: mesh.geometry,
@@ -16691,19 +18454,20 @@ class DebugUI {
16691
18454
  this._addStyles();
16692
18455
  this._createStatsFolder();
16693
18456
  this._createRenderingFolder();
16694
- this._createAOFolder();
16695
- this._createShadowFolder();
16696
- this._createMainLightFolder();
18457
+ this._createCameraFolder();
16697
18458
  this._createEnvironmentFolder();
16698
18459
  this._createLightingFolder();
16699
- this._createCullingFolder();
16700
- this._createSSGIFolder();
16701
- this._createBloomFolder();
18460
+ this._createMainLightFolder();
18461
+ this._createShadowFolder();
16702
18462
  this._createPlanarReflectionFolder();
18463
+ this._createAOFolder();
16703
18464
  this._createAmbientCaptureFolder();
16704
- this._createNoiseFolder();
18465
+ this._createSSGIFolder();
18466
+ this._createVolumetricFogFolder();
18467
+ this._createBloomFolder();
18468
+ this._createTonemapFolder();
16705
18469
  this._createDitheringFolder();
16706
- this._createCameraFolder();
18470
+ this._createCRTFolder();
16707
18471
  this._createDebugFolder();
16708
18472
  for (const folder of Object.values(this.folders)) {
16709
18473
  folder.close();
@@ -16847,6 +18611,33 @@ Lights: ${fmt(visibleLights)} (occ:${fmt(lightOccCulled)})`;
16847
18611
  this.gui.domElement.style.display = "none";
16848
18612
  }
16849
18613
  });
18614
+ if (s.culling) {
18615
+ const cullFolder = folder.addFolder("Culling");
18616
+ cullFolder.add(s.culling, "frustumEnabled").name("Frustum Culling");
18617
+ if (s.occlusionCulling) {
18618
+ cullFolder.add(s.occlusionCulling, "enabled").name("Occlusion Culling");
18619
+ cullFolder.add(s.occlusionCulling, "threshold", 0.1, 2, 0.1).name("Occlusion Threshold");
18620
+ }
18621
+ if (s.culling.planarReflection) {
18622
+ const prFolder = cullFolder.addFolder("Planar Reflection");
18623
+ prFolder.add(s.culling.planarReflection, "frustum").name("Frustum Culling");
18624
+ prFolder.add(s.culling.planarReflection, "maxDistance", 10, 200, 10).name("Max Distance");
18625
+ prFolder.add(s.culling.planarReflection, "maxSkinned", 0, 100, 1).name("Max Skinned");
18626
+ prFolder.add(s.culling.planarReflection, "minPixelSize", 0, 16, 1).name("Min Pixel Size");
18627
+ prFolder.close();
18628
+ }
18629
+ cullFolder.close();
18630
+ }
18631
+ if (s.noise) {
18632
+ const noiseFolder = folder.addFolder("Noise");
18633
+ noiseFolder.add(s.noise, "type", ["bluenoise", "bayer8"]).name("Type").onChange(() => {
18634
+ if (this.engine.renderer?.renderGraph) {
18635
+ this.engine.renderer.renderGraph.reloadNoiseTexture();
18636
+ }
18637
+ });
18638
+ noiseFolder.add(s.noise, "animated").name("Animated");
18639
+ noiseFolder.close();
18640
+ }
16850
18641
  }
16851
18642
  _createAOFolder() {
16852
18643
  const s = this.engine.settings;
@@ -16925,6 +18716,8 @@ Lights: ${fmt(visibleLights)} (occ:${fmt(lightOccCulled)})`;
16925
18716
  fogFolder.add(s.environment.fog.heightFade, "0", -100, 100, 1).name("Bottom Y");
16926
18717
  fogFolder.add(s.environment.fog.heightFade, "1", -50, 200, 5).name("Top Y");
16927
18718
  fogFolder.add(s.environment.fog, "brightResist", 0, 1, 0.05).name("Bright Resist");
18719
+ if (s.environment.fog.debug === void 0) s.environment.fog.debug = 0;
18720
+ fogFolder.add(s.environment.fog, "debug", 0, 10, 1).name("Debug Mode");
16928
18721
  }
16929
18722
  }
16930
18723
  _createLightingFolder() {
@@ -16941,25 +18734,6 @@ Lights: ${fmt(visibleLights)} (occ:${fmt(lightOccCulled)})`;
16941
18734
  folder.add(s.lighting, "specularBoostRoughnessCutoff", 0.1, 1, 0.05).name("Boost Roughness Cutoff");
16942
18735
  }
16943
18736
  }
16944
- _createCullingFolder() {
16945
- const s = this.engine.settings;
16946
- if (!s.culling) return;
16947
- const folder = this.gui.addFolder("Culling");
16948
- this.folders.culling = folder;
16949
- folder.add(s.culling, "frustumEnabled").name("Frustum Culling");
16950
- if (s.occlusionCulling) {
16951
- folder.add(s.occlusionCulling, "enabled").name("Occlusion Culling");
16952
- folder.add(s.occlusionCulling, "threshold", 0.1, 2, 0.1).name("Occlusion Threshold");
16953
- }
16954
- if (s.culling.planarReflection) {
16955
- const prFolder = folder.addFolder("Planar Reflection");
16956
- prFolder.add(s.culling.planarReflection, "frustum").name("Frustum Culling");
16957
- prFolder.add(s.culling.planarReflection, "maxDistance", 10, 200, 10).name("Max Distance");
16958
- prFolder.add(s.culling.planarReflection, "maxSkinned", 0, 100, 1).name("Max Skinned");
16959
- prFolder.add(s.culling.planarReflection, "minPixelSize", 0, 16, 1).name("Min Pixel Size");
16960
- prFolder.close();
16961
- }
16962
- }
16963
18737
  _createSSGIFolder() {
16964
18738
  const s = this.engine.settings;
16965
18739
  if (!s.ssgi) return;
@@ -16972,6 +18746,57 @@ Lights: ${fmt(visibleLights)} (occ:${fmt(lightOccCulled)})`;
16972
18746
  folder.add(s.ssgi, "sampleRadius", 0.5, 4, 0.5).name("Sample Radius");
16973
18747
  folder.add(s.ssgi, "saturateLevel", 0.1, 2, 0.1).name("Saturate Level");
16974
18748
  }
18749
+ _createVolumetricFogFolder() {
18750
+ const s = this.engine.settings;
18751
+ if (!s.volumetricFog) return;
18752
+ const vf = s.volumetricFog;
18753
+ if (vf.density === void 0 && vf.densityMultiplier === void 0) vf.density = 0.5;
18754
+ if (vf.scatterStrength === void 0) vf.scatterStrength = 1;
18755
+ if (!vf.heightRange) vf.heightRange = [-5, 20];
18756
+ if (vf.resolution === void 0) vf.resolution = 0.25;
18757
+ if (vf.maxSamples === void 0) vf.maxSamples = 32;
18758
+ if (vf.blurRadius === void 0) vf.blurRadius = 4;
18759
+ if (vf.noiseStrength === void 0) vf.noiseStrength = 1;
18760
+ if (vf.noiseScale === void 0) vf.noiseScale = 0.25;
18761
+ if (vf.noiseAnimated === void 0) vf.noiseAnimated = true;
18762
+ if (vf.shadowsEnabled === void 0) vf.shadowsEnabled = true;
18763
+ if (vf.mainLightScatter === void 0) vf.mainLightScatter = 1;
18764
+ if (vf.mainLightScatterDark === void 0) vf.mainLightScatterDark = 3;
18765
+ if (vf.mainLightSaturation === void 0) vf.mainLightSaturation = 1;
18766
+ if (vf.brightnessThreshold === void 0) vf.brightnessThreshold = 1;
18767
+ if (vf.minVisibility === void 0) vf.minVisibility = 0.15;
18768
+ if (vf.skyBrightness === void 0) vf.skyBrightness = 5;
18769
+ if (vf.debug === void 0) vf.debug = 0;
18770
+ const folder = this.gui.addFolder("Volumetric Fog");
18771
+ this.folders.volumetricFog = folder;
18772
+ folder.add(vf, "enabled").name("Enabled");
18773
+ if (vf.density !== void 0) {
18774
+ folder.add(vf, "density", 0, 2, 0.05).name("Density");
18775
+ } else if (vf.densityMultiplier !== void 0) {
18776
+ folder.add(vf, "densityMultiplier", 0, 2, 0.05).name("Density");
18777
+ }
18778
+ folder.add(vf, "scatterStrength", 0, 10, 0.1).name("Scatter (Lights)");
18779
+ folder.add(vf, "mainLightScatter", 0, 5, 0.1).name("Sun Scatter (Light)");
18780
+ folder.add(vf, "mainLightScatterDark", 0, 10, 0.1).name("Sun Scatter (Dark)");
18781
+ folder.add(vf, "mainLightSaturation", 0, 1, 0.01).name("Sun Saturation");
18782
+ folder.add(vf, "brightnessThreshold", 0.1, 5, 0.1).name("Bright Threshold");
18783
+ folder.add(vf, "minVisibility", 0, 1, 0.05).name("Min Visibility");
18784
+ folder.add(vf, "skyBrightness", 0, 10, 0.5).name("Sky Brightness");
18785
+ folder.add(vf.heightRange, "0", -50, 50, 1).name("Height Bottom");
18786
+ folder.add(vf.heightRange, "1", -10, 100, 1).name("Height Top");
18787
+ const qualityFolder = folder.addFolder("Quality");
18788
+ qualityFolder.add(vf, "resolution", 0.125, 0.5, 0.125).name("Resolution");
18789
+ qualityFolder.add(vf, "maxSamples", 16, 128, 8).name("Max Samples");
18790
+ qualityFolder.add(vf, "blurRadius", 0, 8, 1).name("Blur Radius");
18791
+ qualityFolder.close();
18792
+ const noiseFolder = folder.addFolder("Noise");
18793
+ noiseFolder.add(vf, "noiseStrength", 0, 1, 0.1).name("Strength");
18794
+ noiseFolder.add(vf, "noiseScale", 0.05, 1, 0.05).name("Scale (Detail)");
18795
+ noiseFolder.add(vf, "noiseAnimated").name("Animated");
18796
+ noiseFolder.close();
18797
+ folder.add(vf, "shadowsEnabled").name("Shadows");
18798
+ folder.add(vf, "debug", 0, 12, 1).name("Debug Mode");
18799
+ }
16975
18800
  _createBloomFolder() {
16976
18801
  const s = this.engine.settings;
16977
18802
  if (!s.bloom) return;
@@ -16988,6 +18813,14 @@ Lights: ${fmt(visibleLights)} (occ:${fmt(lightOccCulled)})`;
16988
18813
  folder.add(s.bloom, "scale", 0.25, 1, 0.25).name("Resolution Scale");
16989
18814
  }
16990
18815
  }
18816
+ _createTonemapFolder() {
18817
+ const s = this.engine.settings;
18818
+ if (!s.rendering) return;
18819
+ if (s.rendering.tonemapMode === void 0) s.rendering.tonemapMode = 0;
18820
+ const folder = this.gui.addFolder("Tone Mapping");
18821
+ this.folders.tonemap = folder;
18822
+ folder.add(s.rendering, "tonemapMode", { "ACES": 0, "Reinhard": 1, "None (Linear)": 2 }).name("Mode");
18823
+ }
16991
18824
  _createPlanarReflectionFolder() {
16992
18825
  const s = this.engine.settings;
16993
18826
  if (!s.planarReflection) return;
@@ -17003,7 +18836,7 @@ Lights: ${fmt(visibleLights)} (occ:${fmt(lightOccCulled)})`;
17003
18836
  _createAmbientCaptureFolder() {
17004
18837
  const s = this.engine.settings;
17005
18838
  if (!s.ambientCapture) return;
17006
- const folder = this.gui.addFolder("Ambient Capture");
18839
+ const folder = this.gui.addFolder("Probe GI (Ambient Capture)");
17007
18840
  this.folders.ambientCapture = folder;
17008
18841
  folder.add(s.ambientCapture, "enabled").name("Enabled");
17009
18842
  folder.add(s.ambientCapture, "intensity", 0, 2, 0.05).name("Intensity");
@@ -17011,14 +18844,6 @@ Lights: ${fmt(visibleLights)} (occ:${fmt(lightOccCulled)})`;
17011
18844
  folder.add(s.ambientCapture, "emissiveBoost", 0, 10, 0.1).name("Emissive Boost");
17012
18845
  folder.add(s.ambientCapture, "saturateLevel", 0, 2, 0.05).name("Saturate Level");
17013
18846
  }
17014
- _createNoiseFolder() {
17015
- const s = this.engine.settings;
17016
- if (!s.noise) return;
17017
- const folder = this.gui.addFolder("Noise");
17018
- this.folders.noise = folder;
17019
- folder.add(s.noise, "type", ["bluenoise", "bayer8"]).name("Type");
17020
- folder.add(s.noise, "animated").name("Animated");
17021
- }
17022
18847
  _createDitheringFolder() {
17023
18848
  const s = this.engine.settings;
17024
18849
  if (!s.dithering) return;
@@ -17027,6 +18852,40 @@ Lights: ${fmt(visibleLights)} (occ:${fmt(lightOccCulled)})`;
17027
18852
  folder.add(s.dithering, "enabled").name("Enabled");
17028
18853
  folder.add(s.dithering, "colorLevels", 4, 256, 1).name("Color Levels");
17029
18854
  }
18855
+ _createCRTFolder() {
18856
+ const s = this.engine.settings;
18857
+ if (!s.crt) return;
18858
+ const folder = this.gui.addFolder("CRT Effect");
18859
+ this.folders.crt = folder;
18860
+ folder.add(s.crt, "enabled").name("CRT Enabled");
18861
+ folder.add(s.crt, "upscaleEnabled").name("Upscale Only");
18862
+ folder.add(s.crt, "upscaleTarget", 1, 8, 1).name("Upscale Target");
18863
+ const geomFolder = folder.addFolder("Geometry");
18864
+ geomFolder.add(s.crt, "curvature", 0, 0.25, 5e-3).name("Curvature");
18865
+ geomFolder.add(s.crt, "cornerRadius", 0, 0.2, 5e-3).name("Corner Radius");
18866
+ geomFolder.add(s.crt, "zoom", 1, 1.25, 5e-3).name("Zoom");
18867
+ geomFolder.close();
18868
+ const scanFolder = folder.addFolder("Scanlines");
18869
+ scanFolder.add(s.crt, "scanlineIntensity", 0, 1, 0.05).name("Intensity");
18870
+ scanFolder.add(s.crt, "scanlineWidth", 0, 1, 0.05).name("Width");
18871
+ scanFolder.add(s.crt, "scanlineBrightBoost", 0, 2, 0.05).name("Bright Boost");
18872
+ scanFolder.add(s.crt, "scanlineHeight", 1, 10, 1).name("Height (px)");
18873
+ scanFolder.close();
18874
+ const convFolder = folder.addFolder("RGB Convergence");
18875
+ convFolder.add(s.crt.convergence, "0", -3, 3, 0.1).name("Red X Offset");
18876
+ convFolder.add(s.crt.convergence, "1", -3, 3, 0.1).name("Green X Offset");
18877
+ convFolder.add(s.crt.convergence, "2", -3, 3, 0.1).name("Blue X Offset");
18878
+ convFolder.close();
18879
+ const maskFolder = folder.addFolder("Phosphor Mask");
18880
+ maskFolder.add(s.crt, "maskType", ["none", "aperture", "slot", "shadow"]).name("Type");
18881
+ maskFolder.add(s.crt, "maskIntensity", 0, 1, 0.05).name("Intensity");
18882
+ maskFolder.close();
18883
+ const vigFolder = folder.addFolder("Vignette");
18884
+ vigFolder.add(s.crt, "vignetteIntensity", 0, 1, 0.05).name("Intensity");
18885
+ vigFolder.add(s.crt, "vignetteSize", 0.1, 1, 0.05).name("Size");
18886
+ vigFolder.close();
18887
+ folder.add(s.crt, "blurSize", 0, 8, 0.1).name("H-Blur (px)");
18888
+ }
17030
18889
  _createCameraFolder() {
17031
18890
  const s = this.engine.settings;
17032
18891
  if (!s.camera) return;
@@ -17106,6 +18965,619 @@ Lights: ${fmt(visibleLights)} (occ:${fmt(lightOccCulled)})`;
17106
18965
  } : { r: 1, g: 1, b: 1 };
17107
18966
  }
17108
18967
  }
18968
+ class Raycaster {
18969
+ constructor(engine) {
18970
+ this.engine = engine;
18971
+ this.worker = null;
18972
+ this._pendingCallbacks = /* @__PURE__ */ new Map();
18973
+ this._nextRequestId = 0;
18974
+ this._initialized = false;
18975
+ }
18976
+ async initialize() {
18977
+ const workerCode = this._getWorkerCode();
18978
+ const blob = new Blob([workerCode], { type: "application/javascript" });
18979
+ const workerUrl = URL.createObjectURL(blob);
18980
+ this.worker = new Worker(workerUrl);
18981
+ this.worker.onmessage = this._handleWorkerMessage.bind(this);
18982
+ this.worker.onerror = (e) => console.error("Raycaster worker error:", e);
18983
+ this._initialized = true;
18984
+ URL.revokeObjectURL(workerUrl);
18985
+ }
18986
+ /**
18987
+ * Cast a ray and get the closest intersection
18988
+ * @param {Array|vec3} origin - Ray start point [x, y, z]
18989
+ * @param {Array|vec3} direction - Ray direction (will be normalized)
18990
+ * @param {number} maxDistance - Maximum ray length
18991
+ * @param {Function} callback - Called with result: { hit, distance, point, normal, entity, mesh, triangleIndex }
18992
+ * @param {Object} options - Optional settings
18993
+ * @param {Array} options.entities - Specific entities to test (default: all scene entities)
18994
+ * @param {Array} options.meshes - Specific meshes to test (default: all scene meshes)
18995
+ * @param {boolean} options.backfaces - Test backfaces (default: false)
18996
+ * @param {Array} options.exclude - Entities/meshes to exclude
18997
+ */
18998
+ cast(origin, direction, maxDistance, callback, options = {}) {
18999
+ if (!this._initialized) {
19000
+ console.warn("Raycaster not initialized");
19001
+ callback({ hit: false, error: "not initialized" });
19002
+ return;
19003
+ }
19004
+ const ray = {
19005
+ origin: Array.from(origin),
19006
+ direction: this._normalize(Array.from(direction)),
19007
+ maxDistance
19008
+ };
19009
+ const candidates = this._collectCandidates(ray, options);
19010
+ if (candidates.length === 0) {
19011
+ callback({ hit: false });
19012
+ return;
19013
+ }
19014
+ const requestId = this._nextRequestId++;
19015
+ this._pendingCallbacks.set(requestId, { callback, candidates });
19016
+ this.worker.postMessage({
19017
+ type: "raycast",
19018
+ requestId,
19019
+ ray,
19020
+ debug: options.debug ?? false,
19021
+ candidates: candidates.map((c) => ({
19022
+ id: c.id,
19023
+ vertices: c.vertices,
19024
+ indices: c.indices,
19025
+ matrix: c.matrix,
19026
+ backfaces: options.backfaces ?? false
19027
+ }))
19028
+ });
19029
+ }
19030
+ /**
19031
+ * Cast a ray upward from a position to check for sky visibility
19032
+ * Useful for determining if camera is under cover
19033
+ * @param {Array|vec3} position - Position to test from
19034
+ * @param {number} maxDistance - How far to check (default: 100)
19035
+ * @param {Function} callback - Called with { hitSky: boolean, distance?: number, entity?: object }
19036
+ */
19037
+ castToSky(position, maxDistance, callback) {
19038
+ this.cast(
19039
+ position,
19040
+ [0, 1, 0],
19041
+ // Straight up
19042
+ maxDistance ?? 100,
19043
+ (result) => {
19044
+ callback({
19045
+ hitSky: !result.hit,
19046
+ distance: result.distance,
19047
+ entity: result.entity,
19048
+ mesh: result.mesh
19049
+ });
19050
+ }
19051
+ );
19052
+ }
19053
+ /**
19054
+ * Cast a ray from screen coordinates (mouse picking)
19055
+ * @param {number} screenX - Screen X coordinate
19056
+ * @param {number} screenY - Screen Y coordinate
19057
+ * @param {Object} camera - Camera with projection/view matrices
19058
+ * @param {Function} callback - Called with intersection result
19059
+ * @param {Object} options - Cast options
19060
+ */
19061
+ castFromScreen(screenX, screenY, camera, callback, options = {}) {
19062
+ const { width, height } = this.engine.canvas;
19063
+ const ndcX = screenX / width * 2 - 1;
19064
+ const ndcY = 1 - screenY / height * 2;
19065
+ const invViewProj = mat4$1.create();
19066
+ mat4$1.multiply(invViewProj, camera.proj, camera.view);
19067
+ mat4$1.invert(invViewProj, invViewProj);
19068
+ const nearPoint = this._unproject([ndcX, ndcY, 0], invViewProj);
19069
+ const farPoint = this._unproject([ndcX, ndcY, 1], invViewProj);
19070
+ const direction = [
19071
+ farPoint[0] - nearPoint[0],
19072
+ farPoint[1] - nearPoint[1],
19073
+ farPoint[2] - nearPoint[2]
19074
+ ];
19075
+ const maxDistance = options.maxDistance ?? camera.far ?? 1e3;
19076
+ this.cast(nearPoint, direction, maxDistance, callback, options);
19077
+ }
19078
+ /**
19079
+ * Collect candidate geometries that pass bounding sphere test
19080
+ */
19081
+ _collectCandidates(ray, options) {
19082
+ const candidates = [];
19083
+ const exclude = new Set(options.exclude ?? []);
19084
+ const debug = options.debug;
19085
+ const entities = options.entities ?? this._getAllEntities();
19086
+ const assetManager = this.engine.assetManager;
19087
+ for (const entity of entities) {
19088
+ if (exclude.has(entity)) continue;
19089
+ if (!entity.model) continue;
19090
+ const asset = assetManager?.get(entity.model);
19091
+ if (!asset?.geometry) continue;
19092
+ const bsphere = this._getEntityBoundingSphere(entity);
19093
+ if (!bsphere) continue;
19094
+ if (this._raySphereIntersect(ray, bsphere)) {
19095
+ const geometryData = this._extractGeometry(asset.geometry);
19096
+ if (geometryData) {
19097
+ const matrix = entity._matrix ?? mat4$1.create();
19098
+ candidates.push({
19099
+ id: entity.id ?? entity.name ?? `entity_${candidates.length}`,
19100
+ type: "entity",
19101
+ entity,
19102
+ asset,
19103
+ vertices: geometryData.vertices,
19104
+ indices: geometryData.indices,
19105
+ matrix: Array.from(matrix),
19106
+ bsphereDistance: this._raySphereDistance(ray, bsphere)
19107
+ });
19108
+ }
19109
+ }
19110
+ }
19111
+ const meshes = options.meshes ?? this._getAllMeshes();
19112
+ let debugStats = debug ? { total: 0, noGeom: 0, noBsphere: 0, noData: 0, sphereMiss: 0, candidates: 0 } : null;
19113
+ for (const [name, mesh] of Object.entries(meshes)) {
19114
+ if (exclude.has(mesh)) continue;
19115
+ if (!mesh.geometry) {
19116
+ if (debug) debugStats.noGeom++;
19117
+ continue;
19118
+ }
19119
+ const bsphere = this._getMeshBoundingSphere(mesh);
19120
+ if (!bsphere) {
19121
+ if (debug) debugStats.noBsphere++;
19122
+ continue;
19123
+ }
19124
+ const geometryData = this._extractGeometry(mesh.geometry);
19125
+ if (!geometryData) {
19126
+ if (debug) debugStats.noData++;
19127
+ continue;
19128
+ }
19129
+ if (debug) debugStats.total++;
19130
+ let instanceCount = mesh.geometry.instanceCount ?? 0;
19131
+ if (instanceCount === 0) {
19132
+ if (mesh.geometry.instanceData) {
19133
+ instanceCount = mesh.static ? mesh.geometry.maxInstances ?? 1 : 1;
19134
+ } else {
19135
+ instanceCount = 1;
19136
+ }
19137
+ }
19138
+ for (let i = 0; i < instanceCount; i++) {
19139
+ const matrix = this._getInstanceMatrix(mesh.geometry, i);
19140
+ const instanceBsphere = this._transformBoundingSphere(bsphere, matrix);
19141
+ if (this._raySphereIntersect(ray, instanceBsphere)) {
19142
+ if (debug) debugStats.candidates++;
19143
+ candidates.push({
19144
+ id: `${name}_${i}`,
19145
+ type: "mesh",
19146
+ mesh,
19147
+ meshName: name,
19148
+ instanceIndex: i,
19149
+ vertices: geometryData.vertices,
19150
+ indices: geometryData.indices,
19151
+ matrix: Array.from(matrix),
19152
+ bsphereDistance: this._raySphereDistance(ray, instanceBsphere)
19153
+ });
19154
+ } else {
19155
+ if (debug) debugStats.sphereMiss++;
19156
+ }
19157
+ }
19158
+ }
19159
+ if (debug && debugStats) {
19160
+ console.log(`Raycaster: meshes=${debugStats.total}, sphereHit=${debugStats.candidates}, sphereMiss=${debugStats.sphereMiss}`);
19161
+ if (candidates.length > 0 && candidates.length < 50) {
19162
+ const candInfo = candidates.map((c) => {
19163
+ const m = c.matrix;
19164
+ const pos = [m[12], m[13], m[14]];
19165
+ return `${c.id}@[${pos.map((v) => v.toFixed(1)).join(",")}]`;
19166
+ }).join(", ");
19167
+ console.log(`Candidates: ${candInfo}`);
19168
+ }
19169
+ }
19170
+ candidates.sort((a, b) => a.bsphereDistance - b.bsphereDistance);
19171
+ return candidates;
19172
+ }
19173
+ _getAllEntities() {
19174
+ const entities = this.engine.entities;
19175
+ if (!entities) return [];
19176
+ return Object.values(entities);
19177
+ }
19178
+ _getAllMeshes() {
19179
+ return this.engine.meshes ?? {};
19180
+ }
19181
+ _getEntityBoundingSphere(entity) {
19182
+ if (entity._bsphere && entity._bsphere.radius > 0) {
19183
+ return {
19184
+ center: Array.from(entity._bsphere.center),
19185
+ radius: entity._bsphere.radius
19186
+ };
19187
+ }
19188
+ const geometry = entity.mesh?.geometry;
19189
+ if (!geometry) return null;
19190
+ const localBsphere = geometry.getBoundingSphere?.();
19191
+ if (!localBsphere || localBsphere.radius <= 0) return null;
19192
+ const matrix = entity._matrix ?? entity.matrix ?? mat4$1.create();
19193
+ return this._transformBoundingSphere(localBsphere, matrix);
19194
+ }
19195
+ _getMeshBoundingSphere(mesh) {
19196
+ const geometry = mesh.geometry;
19197
+ if (!geometry) return null;
19198
+ return geometry.getBoundingSphere?.() ?? null;
19199
+ }
19200
+ _transformBoundingSphere(bsphere, matrix) {
19201
+ const center = vec3$1.create();
19202
+ vec3$1.transformMat4(center, bsphere.center, matrix);
19203
+ const scaleX = Math.sqrt(matrix[0] * matrix[0] + matrix[1] * matrix[1] + matrix[2] * matrix[2]);
19204
+ const scaleY = Math.sqrt(matrix[4] * matrix[4] + matrix[5] * matrix[5] + matrix[6] * matrix[6]);
19205
+ const scaleZ = Math.sqrt(matrix[8] * matrix[8] + matrix[9] * matrix[9] + matrix[10] * matrix[10]);
19206
+ const maxScale = Math.max(scaleX, scaleY, scaleZ);
19207
+ return {
19208
+ center: Array.from(center),
19209
+ radius: bsphere.radius * maxScale
19210
+ };
19211
+ }
19212
+ _getInstanceMatrix(geometry, instanceIndex) {
19213
+ if (!geometry.instanceData) {
19214
+ return mat4$1.create();
19215
+ }
19216
+ const stride = 28;
19217
+ const offset = instanceIndex * stride;
19218
+ if (offset + 16 > geometry.instanceData.length) {
19219
+ return mat4$1.create();
19220
+ }
19221
+ const matrix = mat4$1.create();
19222
+ for (let i = 0; i < 16; i++) {
19223
+ matrix[i] = geometry.instanceData[offset + i];
19224
+ }
19225
+ return matrix;
19226
+ }
19227
+ _extractGeometry(geometry) {
19228
+ if (!geometry.vertexArray || !geometry.indexArray) {
19229
+ return null;
19230
+ }
19231
+ const stride = 20;
19232
+ const vertexCount = geometry.vertexArray.length / stride;
19233
+ const vertices = new Float32Array(vertexCount * 3);
19234
+ for (let i = 0; i < vertexCount; i++) {
19235
+ vertices[i * 3] = geometry.vertexArray[i * stride];
19236
+ vertices[i * 3 + 1] = geometry.vertexArray[i * stride + 1];
19237
+ vertices[i * 3 + 2] = geometry.vertexArray[i * stride + 2];
19238
+ }
19239
+ return {
19240
+ vertices,
19241
+ indices: geometry.indexArray
19242
+ };
19243
+ }
19244
+ /**
19245
+ * Ray-sphere intersection test
19246
+ * Returns true if ray intersects sphere within maxDistance
19247
+ * Handles case where ray origin is inside the sphere
19248
+ */
19249
+ _raySphereIntersect(ray, sphere) {
19250
+ const oc = [
19251
+ ray.origin[0] - sphere.center[0],
19252
+ ray.origin[1] - sphere.center[1],
19253
+ ray.origin[2] - sphere.center[2]
19254
+ ];
19255
+ const distToCenter = Math.sqrt(oc[0] * oc[0] + oc[1] * oc[1] + oc[2] * oc[2]);
19256
+ if (distToCenter < sphere.radius) {
19257
+ return true;
19258
+ }
19259
+ const a = this._dot(ray.direction, ray.direction);
19260
+ const b = 2 * this._dot(oc, ray.direction);
19261
+ const c = this._dot(oc, oc) - sphere.radius * sphere.radius;
19262
+ const discriminant = b * b - 4 * a * c;
19263
+ if (discriminant < 0) return false;
19264
+ const sqrtDisc = Math.sqrt(discriminant);
19265
+ const t1 = (-b - sqrtDisc) / (2 * a);
19266
+ const t2 = (-b + sqrtDisc) / (2 * a);
19267
+ if (t1 >= 0 && t1 <= ray.maxDistance) return true;
19268
+ if (t2 >= 0 && t2 <= ray.maxDistance) return true;
19269
+ return false;
19270
+ }
19271
+ /**
19272
+ * Get distance to sphere along ray (for sorting)
19273
+ */
19274
+ _raySphereDistance(ray, sphere) {
19275
+ const oc = [
19276
+ ray.origin[0] - sphere.center[0],
19277
+ ray.origin[1] - sphere.center[1],
19278
+ ray.origin[2] - sphere.center[2]
19279
+ ];
19280
+ const a = this._dot(ray.direction, ray.direction);
19281
+ const b = 2 * this._dot(oc, ray.direction);
19282
+ const c = this._dot(oc, oc) - sphere.radius * sphere.radius;
19283
+ const discriminant = b * b - 4 * a * c;
19284
+ if (discriminant < 0) return Infinity;
19285
+ const t = (-b - Math.sqrt(discriminant)) / (2 * a);
19286
+ return Math.max(0, t);
19287
+ }
19288
+ _handleWorkerMessage(event) {
19289
+ const { type, requestId, result } = event.data;
19290
+ if (type === "raycastResult") {
19291
+ const pending = this._pendingCallbacks.get(requestId);
19292
+ if (pending) {
19293
+ this._pendingCallbacks.delete(requestId);
19294
+ if (result.hit && pending.candidates) {
19295
+ const candidate = pending.candidates.find((c) => c.id === result.candidateId);
19296
+ if (candidate) {
19297
+ result.entity = candidate.entity;
19298
+ result.mesh = candidate.mesh;
19299
+ result.meshName = candidate.meshName;
19300
+ result.instanceIndex = candidate.instanceIndex;
19301
+ }
19302
+ }
19303
+ pending.callback(result);
19304
+ }
19305
+ }
19306
+ }
19307
+ _normalize(v) {
19308
+ const len = Math.sqrt(v[0] * v[0] + v[1] * v[1] + v[2] * v[2]);
19309
+ if (len === 0) return [0, 0, 1];
19310
+ return [v[0] / len, v[1] / len, v[2] / len];
19311
+ }
19312
+ _dot(a, b) {
19313
+ return a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
19314
+ }
19315
+ /**
19316
+ * Get perpendicular distance from a point to a ray
19317
+ */
19318
+ _pointToRayDistance(point, ray) {
19319
+ const op = [
19320
+ point[0] - ray.origin[0],
19321
+ point[1] - ray.origin[1],
19322
+ point[2] - ray.origin[2]
19323
+ ];
19324
+ const t = this._dot(op, ray.direction);
19325
+ const closest = [
19326
+ ray.origin[0] + ray.direction[0] * t,
19327
+ ray.origin[1] + ray.direction[1] * t,
19328
+ ray.origin[2] + ray.direction[2] * t
19329
+ ];
19330
+ const dx = point[0] - closest[0];
19331
+ const dy = point[1] - closest[1];
19332
+ const dz = point[2] - closest[2];
19333
+ return Math.sqrt(dx * dx + dy * dy + dz * dz);
19334
+ }
19335
+ _unproject(ndc, invViewProj) {
19336
+ const x = ndc[0];
19337
+ const y = ndc[1];
19338
+ const z = ndc[2];
19339
+ const w = invViewProj[3] * x + invViewProj[7] * y + invViewProj[11] * z + invViewProj[15];
19340
+ return [
19341
+ (invViewProj[0] * x + invViewProj[4] * y + invViewProj[8] * z + invViewProj[12]) / w,
19342
+ (invViewProj[1] * x + invViewProj[5] * y + invViewProj[9] * z + invViewProj[13]) / w,
19343
+ (invViewProj[2] * x + invViewProj[6] * y + invViewProj[10] * z + invViewProj[14]) / w
19344
+ ];
19345
+ }
19346
+ /**
19347
+ * Generate Web Worker code as string
19348
+ */
19349
+ _getWorkerCode() {
19350
+ return `
19351
+ // Raycaster Web Worker
19352
+ // Performs triangle intersection tests off the main thread
19353
+
19354
+ self.onmessage = function(event) {
19355
+ const { type, requestId, ray, candidates, debug } = event.data
19356
+
19357
+ if (type === 'raycast') {
19358
+ const result = raycastTriangles(ray, candidates, debug)
19359
+ self.postMessage({ type: 'raycastResult', requestId, result })
19360
+ }
19361
+ }
19362
+
19363
+ function raycastTriangles(ray, candidates, debug) {
19364
+ let closestHit = null
19365
+ let closestDistance = ray.maxDistance
19366
+ let debugInfo = debug ? { totalTris: 0, testedCandidates: 0, scales: [] } : null
19367
+
19368
+ for (const candidate of candidates) {
19369
+ if (debug) debugInfo.testedCandidates++
19370
+ const result = testCandidate(ray, candidate, closestDistance, debug ? debugInfo : null)
19371
+
19372
+ if (result && result.distance < closestDistance) {
19373
+ closestDistance = result.distance
19374
+ closestHit = {
19375
+ hit: true,
19376
+ distance: result.distance,
19377
+ point: result.point,
19378
+ normal: result.normal,
19379
+ triangleIndex: result.triangleIndex,
19380
+ candidateId: candidate.id,
19381
+ localT: result.localT,
19382
+ scale: result.scale
19383
+ }
19384
+ }
19385
+ }
19386
+
19387
+ if (debug) {
19388
+ let msg = 'Worker: candidates=' + debugInfo.testedCandidates + ', triangles=' + debugInfo.totalTris
19389
+ if (closestHit) {
19390
+ msg += ', hit=' + closestHit.distance.toFixed(2) + ' (localT=' + closestHit.localT.toFixed(2) + ', scale=' + closestHit.scale.toFixed(2) + ')'
19391
+ } else {
19392
+ msg += ', hit=none'
19393
+ }
19394
+ console.log(msg)
19395
+ }
19396
+
19397
+ return closestHit ?? { hit: false }
19398
+ }
19399
+
19400
+ function testCandidate(ray, candidate, maxDistance, debugInfo) {
19401
+ const { vertices, indices, matrix, backfaces } = candidate
19402
+
19403
+ // Compute inverse matrix for transforming ray to local space
19404
+ const invMatrix = invertMatrix4(matrix)
19405
+
19406
+ // Transform ray to local space
19407
+ const localOrigin = transformPoint(ray.origin, invMatrix)
19408
+ const localDir = transformDirection(ray.direction, invMatrix)
19409
+
19410
+ // Calculate the scale factor of the transformation (for correct distance)
19411
+ const dirScale = Math.sqrt(localDir[0]*localDir[0] + localDir[1]*localDir[1] + localDir[2]*localDir[2])
19412
+ const localDirNorm = [localDir[0]/dirScale, localDir[1]/dirScale, localDir[2]/dirScale]
19413
+
19414
+ let closestHit = null
19415
+ let closestT = maxDistance
19416
+
19417
+ // Test each triangle
19418
+ const triangleCount = indices.length / 3
19419
+ if (debugInfo) debugInfo.totalTris += triangleCount
19420
+ for (let i = 0; i < triangleCount; i++) {
19421
+ const i0 = indices[i * 3]
19422
+ const i1 = indices[i * 3 + 1]
19423
+ const i2 = indices[i * 3 + 2]
19424
+
19425
+ const v0 = [vertices[i0 * 3], vertices[i0 * 3 + 1], vertices[i0 * 3 + 2]]
19426
+ const v1 = [vertices[i1 * 3], vertices[i1 * 3 + 1], vertices[i1 * 3 + 2]]
19427
+ const v2 = [vertices[i2 * 3], vertices[i2 * 3 + 1], vertices[i2 * 3 + 2]]
19428
+
19429
+ const hit = rayTriangleIntersect(localOrigin, localDirNorm, v0, v1, v2, backfaces)
19430
+
19431
+ if (hit && hit.t > 0) {
19432
+ // Transform hit point and normal back to world space
19433
+ const worldPoint = transformPoint(hit.point, matrix)
19434
+
19435
+ // Calculate world-space distance (local t may be wrong due to matrix scale)
19436
+ const worldDist = Math.sqrt(
19437
+ (worldPoint[0] - ray.origin[0]) ** 2 +
19438
+ (worldPoint[1] - ray.origin[1]) ** 2 +
19439
+ (worldPoint[2] - ray.origin[2]) ** 2
19440
+ )
19441
+
19442
+ if (worldDist < closestT) {
19443
+ closestT = worldDist
19444
+ const worldNormal = transformDirection(hit.normal, matrix)
19445
+
19446
+ closestHit = {
19447
+ distance: worldDist,
19448
+ point: worldPoint,
19449
+ normal: normalize(worldNormal),
19450
+ triangleIndex: i,
19451
+ localT: hit.t,
19452
+ scale: dirScale
19453
+ }
19454
+ }
19455
+ }
19456
+ }
19457
+
19458
+ return closestHit
19459
+ }
19460
+
19461
+ // Möller–Trumbore intersection algorithm
19462
+ function rayTriangleIntersect(origin, dir, v0, v1, v2, backfaces) {
19463
+ const EPSILON = 0.0000001
19464
+
19465
+ const edge1 = sub(v1, v0)
19466
+ const edge2 = sub(v2, v0)
19467
+ const h = cross(dir, edge2)
19468
+ const a = dot(edge1, h)
19469
+
19470
+ // Check if ray is parallel to triangle
19471
+ if (a > -EPSILON && a < EPSILON) return null
19472
+
19473
+ // Check backface
19474
+ if (!backfaces && a < 0) return null
19475
+
19476
+ const f = 1.0 / a
19477
+ const s = sub(origin, v0)
19478
+ const u = f * dot(s, h)
19479
+
19480
+ if (u < 0.0 || u > 1.0) return null
19481
+
19482
+ const q = cross(s, edge1)
19483
+ const v = f * dot(dir, q)
19484
+
19485
+ if (v < 0.0 || u + v > 1.0) return null
19486
+
19487
+ const t = f * dot(edge2, q)
19488
+
19489
+ if (t > EPSILON) {
19490
+ const point = [
19491
+ origin[0] + dir[0] * t,
19492
+ origin[1] + dir[1] * t,
19493
+ origin[2] + dir[2] * t
19494
+ ]
19495
+ const normal = normalize(cross(edge1, edge2))
19496
+ return { t, point, normal, u, v }
19497
+ }
19498
+
19499
+ return null
19500
+ }
19501
+
19502
+ // Matrix and vector utilities
19503
+ function invertMatrix4(m) {
19504
+ const inv = new Array(16)
19505
+
19506
+ inv[0] = m[5]*m[10]*m[15] - m[5]*m[11]*m[14] - m[9]*m[6]*m[15] + m[9]*m[7]*m[14] + m[13]*m[6]*m[11] - m[13]*m[7]*m[10]
19507
+ inv[4] = -m[4]*m[10]*m[15] + m[4]*m[11]*m[14] + m[8]*m[6]*m[15] - m[8]*m[7]*m[14] - m[12]*m[6]*m[11] + m[12]*m[7]*m[10]
19508
+ inv[8] = m[4]*m[9]*m[15] - m[4]*m[11]*m[13] - m[8]*m[5]*m[15] + m[8]*m[7]*m[13] + m[12]*m[5]*m[11] - m[12]*m[7]*m[9]
19509
+ inv[12] = -m[4]*m[9]*m[14] + m[4]*m[10]*m[13] + m[8]*m[5]*m[14] - m[8]*m[6]*m[13] - m[12]*m[5]*m[10] + m[12]*m[6]*m[9]
19510
+ inv[1] = -m[1]*m[10]*m[15] + m[1]*m[11]*m[14] + m[9]*m[2]*m[15] - m[9]*m[3]*m[14] - m[13]*m[2]*m[11] + m[13]*m[3]*m[10]
19511
+ inv[5] = m[0]*m[10]*m[15] - m[0]*m[11]*m[14] - m[8]*m[2]*m[15] + m[8]*m[3]*m[14] + m[12]*m[2]*m[11] - m[12]*m[3]*m[10]
19512
+ inv[9] = -m[0]*m[9]*m[15] + m[0]*m[11]*m[13] + m[8]*m[1]*m[15] - m[8]*m[3]*m[13] - m[12]*m[1]*m[11] + m[12]*m[3]*m[9]
19513
+ inv[13] = m[0]*m[9]*m[14] - m[0]*m[10]*m[13] - m[8]*m[1]*m[14] + m[8]*m[2]*m[13] + m[12]*m[1]*m[10] - m[12]*m[2]*m[9]
19514
+ inv[2] = m[1]*m[6]*m[15] - m[1]*m[7]*m[14] - m[5]*m[2]*m[15] + m[5]*m[3]*m[14] + m[13]*m[2]*m[7] - m[13]*m[3]*m[6]
19515
+ inv[6] = -m[0]*m[6]*m[15] + m[0]*m[7]*m[14] + m[4]*m[2]*m[15] - m[4]*m[3]*m[14] - m[12]*m[2]*m[7] + m[12]*m[3]*m[6]
19516
+ inv[10] = m[0]*m[5]*m[15] - m[0]*m[7]*m[13] - m[4]*m[1]*m[15] + m[4]*m[3]*m[13] + m[12]*m[1]*m[7] - m[12]*m[3]*m[5]
19517
+ inv[14] = -m[0]*m[5]*m[14] + m[0]*m[6]*m[13] + m[4]*m[1]*m[14] - m[4]*m[2]*m[13] - m[12]*m[1]*m[6] + m[12]*m[2]*m[5]
19518
+ inv[3] = -m[1]*m[6]*m[11] + m[1]*m[7]*m[10] + m[5]*m[2]*m[11] - m[5]*m[3]*m[10] - m[9]*m[2]*m[7] + m[9]*m[3]*m[6]
19519
+ inv[7] = m[0]*m[6]*m[11] - m[0]*m[7]*m[10] - m[4]*m[2]*m[11] + m[4]*m[3]*m[10] + m[8]*m[2]*m[7] - m[8]*m[3]*m[6]
19520
+ inv[11] = -m[0]*m[5]*m[11] + m[0]*m[7]*m[9] + m[4]*m[1]*m[11] - m[4]*m[3]*m[9] - m[8]*m[1]*m[7] + m[8]*m[3]*m[5]
19521
+ inv[15] = m[0]*m[5]*m[10] - m[0]*m[6]*m[9] - m[4]*m[1]*m[10] + m[4]*m[2]*m[9] + m[8]*m[1]*m[6] - m[8]*m[2]*m[5]
19522
+
19523
+ let det = m[0]*inv[0] + m[1]*inv[4] + m[2]*inv[8] + m[3]*inv[12]
19524
+ if (det === 0) return m // Return original if singular
19525
+
19526
+ det = 1.0 / det
19527
+ for (let i = 0; i < 16; i++) inv[i] *= det
19528
+
19529
+ return inv
19530
+ }
19531
+
19532
+ function transformPoint(p, m) {
19533
+ const w = m[3]*p[0] + m[7]*p[1] + m[11]*p[2] + m[15]
19534
+ return [
19535
+ (m[0]*p[0] + m[4]*p[1] + m[8]*p[2] + m[12]) / w,
19536
+ (m[1]*p[0] + m[5]*p[1] + m[9]*p[2] + m[13]) / w,
19537
+ (m[2]*p[0] + m[6]*p[1] + m[10]*p[2] + m[14]) / w
19538
+ ]
19539
+ }
19540
+
19541
+ function transformDirection(d, m) {
19542
+ return [
19543
+ m[0]*d[0] + m[4]*d[1] + m[8]*d[2],
19544
+ m[1]*d[0] + m[5]*d[1] + m[9]*d[2],
19545
+ m[2]*d[0] + m[6]*d[1] + m[10]*d[2]
19546
+ ]
19547
+ }
19548
+
19549
+ function normalize(v) {
19550
+ const len = Math.sqrt(v[0]*v[0] + v[1]*v[1] + v[2]*v[2])
19551
+ if (len === 0) return [0, 0, 1]
19552
+ return [v[0]/len, v[1]/len, v[2]/len]
19553
+ }
19554
+
19555
+ function dot(a, b) {
19556
+ return a[0]*b[0] + a[1]*b[1] + a[2]*b[2]
19557
+ }
19558
+
19559
+ function cross(a, b) {
19560
+ return [
19561
+ a[1]*b[2] - a[2]*b[1],
19562
+ a[2]*b[0] - a[0]*b[2],
19563
+ a[0]*b[1] - a[1]*b[0]
19564
+ ]
19565
+ }
19566
+
19567
+ function sub(a, b) {
19568
+ return [a[0]-b[0], a[1]-b[1], a[2]-b[2]]
19569
+ }
19570
+ `;
19571
+ }
19572
+ destroy() {
19573
+ if (this.worker) {
19574
+ this.worker.terminate();
19575
+ this.worker = null;
19576
+ }
19577
+ this._pendingCallbacks.clear();
19578
+ this._initialized = false;
19579
+ }
19580
+ }
17109
19581
  function fail(engine, msg, data) {
17110
19582
  if (engine?.canvas) {
17111
19583
  engine.canvas.style.display = "none";
@@ -17171,7 +19643,7 @@ const DEFAULT_SETTINGS = {
17171
19643
  renderScale: 1,
17172
19644
  // Render resolution multiplier (1.5-2.0 for supersampling AA)
17173
19645
  autoScale: {
17174
- enabled: false,
19646
+ enabled: true,
17175
19647
  // Auto-reduce renderScale for high resolutions
17176
19648
  enabledForEffects: true,
17177
19649
  // Auto scale effects at high resolutions (when main autoScale disabled)
@@ -17196,8 +19668,10 @@ const DEFAULT_SETTINGS = {
17196
19668
  // Enable alpha hashing/dithering for cutout transparency (global default)
17197
19669
  alphaHashScale: 1,
17198
19670
  // Scale factor for alpha hash threshold (higher = more opaque)
17199
- luminanceToAlpha: false
19671
+ luminanceToAlpha: false,
17200
19672
  // Derive alpha from color luminance (for old game assets where black=transparent)
19673
+ tonemapMode: 0
19674
+ // 0=ACES, 1=Reinhard, 2=None (linear clamp)
17201
19675
  },
17202
19676
  // Noise settings for dithering, jittering, etc.
17203
19677
  noise: {
@@ -17244,13 +19718,14 @@ const DEFAULT_SETTINGS = {
17244
19718
  exposure: 1.6,
17245
19719
  fog: {
17246
19720
  enabled: true,
17247
- color: [0.8, 0.85, 0.9],
17248
- distances: [6, 15, 50],
19721
+ color: [100 / 255, 135 / 255, 170 / 255],
19722
+ distances: [0, 15, 50],
17249
19723
  alpha: [0, 0.5, 0.9],
17250
19724
  heightFade: [-2, 185],
17251
19725
  // [bottomY, topY] - full fog at bottomY, zero at topY
17252
- brightResist: 0.2
19726
+ brightResist: 0,
17253
19727
  // How much bright/emissive colors resist fog (0-1)
19728
+ debug: 0
17254
19729
  }
17255
19730
  },
17256
19731
  // Main directional light
@@ -17280,6 +19755,8 @@ const DEFAULT_SETTINGS = {
17280
19755
  surfaceBias: 0,
17281
19756
  // Scale shadow projection larger (0.01 = 1% larger)
17282
19757
  strength: 1
19758
+ //frustum: false,
19759
+ //hiZ: false,
17283
19760
  },
17284
19761
  // Ambient Occlusion settings
17285
19762
  ao: {
@@ -17387,6 +19864,54 @@ const DEFAULT_SETTINGS = {
17387
19864
  saturateLevel: 0.5
17388
19865
  // Logarithmic saturation level for indirect light
17389
19866
  },
19867
+ // Volumetric Fog (light scattering through particles)
19868
+ volumetricFog: {
19869
+ enabled: false,
19870
+ // Disabled by default (performance impact)
19871
+ resolution: 0.125,
19872
+ // 1/4 render resolution for ray marching
19873
+ maxSamples: 32,
19874
+ // Ray march samples (8-32)
19875
+ blurRadius: 8,
19876
+ // Gaussian blur radius
19877
+ densityMultiplier: 1,
19878
+ // Multiplies base fog density
19879
+ scatterStrength: 0.35,
19880
+ // Light scattering intensity
19881
+ mainLightScatter: 1.4,
19882
+ // Main directional light scattering boost
19883
+ mainLightScatterDark: 5,
19884
+ // Main directional light scattering boost
19885
+ mainLightSaturation: 0.15,
19886
+ // Main light color saturation in fog
19887
+ maxFogOpacity: 0.3,
19888
+ // Maximum fog opacity (0-1)
19889
+ heightRange: [-2, 8],
19890
+ // [bottom, top] Y bounds for fog (low ground fog)
19891
+ windDirection: [1, 0, 0.2],
19892
+ // Wind direction for fog animation
19893
+ windSpeed: 0.5,
19894
+ // Wind speed multiplier
19895
+ noiseScale: 0.9,
19896
+ // 3D noise frequency (higher = finer detail)
19897
+ noiseStrength: 0.8,
19898
+ // Noise intensity (0 = uniform, 1 = full variation)
19899
+ noiseOctaves: 6,
19900
+ // Noise detail layers
19901
+ noiseEnabled: true,
19902
+ // Enable 3D noise (disable for debug)
19903
+ lightingEnabled: true,
19904
+ // Light fog from scene lights
19905
+ shadowsEnabled: true,
19906
+ // Apply shadows to fog
19907
+ brightnessThreshold: 0.8,
19908
+ // Scene luminance where fog starts fading (like bloom)
19909
+ minVisibility: 0.15,
19910
+ // Minimum fog visibility over bright surfaces (0-1)
19911
+ skyBrightness: 1.2
19912
+ // Virtual brightness for sky pixels (depth at far plane)
19913
+ //debugSkyCheck: true
19914
+ },
17390
19915
  // Planar Reflections (alternative to SSR for water/floor)
17391
19916
  planarReflection: {
17392
19917
  enabled: true,
@@ -17442,15 +19967,68 @@ const DEFAULT_SETTINGS = {
17442
19967
  // FPS threshold for auto-disable
17443
19968
  disableDelay: 3
17444
19969
  // Seconds below threshold before disabling
19970
+ },
19971
+ // CRT effect (retro monitor simulation)
19972
+ crt: {
19973
+ enabled: false,
19974
+ // Enable CRT effect (geometry, scanlines, etc.)
19975
+ upscaleEnabled: false,
19976
+ // Enable upscaling (pixelated look) even when CRT disabled
19977
+ upscaleTarget: 4,
19978
+ // Target upscale multiplier (4x render resolution)
19979
+ maxTextureSize: 4096,
19980
+ // Max upscaled texture dimension
19981
+ // Geometry distortion
19982
+ curvature: 0.14,
19983
+ // Screen curvature amount (0-0.15)
19984
+ cornerRadius: 0.055,
19985
+ // Rounded corner radius (0-0.1)
19986
+ zoom: 1.06,
19987
+ // Zoom to compensate for curvature shrinkage
19988
+ // Scanlines (electron beam simulation - Gaussian profile)
19989
+ scanlineIntensity: 0.4,
19990
+ // Scanline effect strength (0-1)
19991
+ scanlineWidth: 0,
19992
+ // Beam width (0=thin/center only, 1=no gap)
19993
+ scanlineBrightBoost: 0.8,
19994
+ // Bright pixels widen beam to fill gaps (0-1)
19995
+ scanlineHeight: 5,
19996
+ // Scanline height in canvas pixels
19997
+ // RGB convergence error (color channel misalignment)
19998
+ convergence: [0.79, 0, -0.77],
19999
+ // RGB X offset in source pixels
20000
+ // Phosphor mask
20001
+ maskType: "aperture",
20002
+ // 'aperture', 'slot', 'shadow', 'none'
20003
+ maskIntensity: 0.25,
20004
+ // Mask strength (0-1)
20005
+ maskScale: 1,
20006
+ // Mask size multiplier
20007
+ // Vignette (edge darkening)
20008
+ vignetteIntensity: 0.54,
20009
+ // Edge darkening strength (0-1)
20010
+ vignetteSize: 0.85,
20011
+ // Vignette size (larger = more visible)
20012
+ // Horizontal blur (beam softness)
20013
+ blurSize: 0.79
20014
+ // Horizontal blur in pixels (0-2)
17445
20015
  }
17446
20016
  };
17447
20017
  async function createWebGPUContext(engine, canvasId) {
17448
20018
  try {
17449
20019
  let configureContext = function() {
17450
- const devicePixelRatio = window.devicePixelRatio || 1;
17451
- const renderScale = engine.renderScale || 1;
17452
- canvas.width = Math.floor(canvas.clientWidth * devicePixelRatio * renderScale) | 0;
17453
- canvas.height = Math.floor(canvas.clientHeight * devicePixelRatio * renderScale) | 0;
20020
+ let pixelWidth, pixelHeight;
20021
+ if (engine._devicePixelSize) {
20022
+ pixelWidth = engine._devicePixelSize.width;
20023
+ pixelHeight = engine._devicePixelSize.height;
20024
+ } else {
20025
+ const devicePixelRatio = window.devicePixelRatio || 1;
20026
+ pixelWidth = Math.round(canvas.clientWidth * devicePixelRatio);
20027
+ pixelHeight = Math.round(canvas.clientHeight * devicePixelRatio);
20028
+ }
20029
+ canvas.width = pixelWidth;
20030
+ canvas.height = pixelHeight;
20031
+ engine._canvasPixelSize = { width: pixelWidth, height: pixelHeight };
17454
20032
  context.configure({
17455
20033
  device,
17456
20034
  format: canvasFormat,
@@ -17623,9 +20201,34 @@ class Engine {
17623
20201
  this.stats.avg_fps = 60;
17624
20202
  this.stats.avg_dt_render = 0.1;
17625
20203
  requestAnimationFrame(() => this._frame());
17626
- window.addEventListener("resize", () => {
17627
- this.needsResize = true;
17628
- });
20204
+ this._devicePixelSize = null;
20205
+ try {
20206
+ const resizeObserver = new ResizeObserver((entries) => {
20207
+ for (const entry of entries) {
20208
+ if (entry.devicePixelContentBoxSize) {
20209
+ const size = entry.devicePixelContentBoxSize[0];
20210
+ this._devicePixelSize = {
20211
+ width: size.inlineSize,
20212
+ height: size.blockSize
20213
+ };
20214
+ } else if (entry.contentBoxSize) {
20215
+ const size = entry.contentBoxSize[0];
20216
+ const dpr = window.devicePixelRatio || 1;
20217
+ this._devicePixelSize = {
20218
+ width: Math.round(size.inlineSize * dpr),
20219
+ height: Math.round(size.blockSize * dpr)
20220
+ };
20221
+ }
20222
+ this.needsResize = true;
20223
+ }
20224
+ });
20225
+ resizeObserver.observe(this.canvas, { box: "device-pixel-content-box" });
20226
+ } catch (e) {
20227
+ console.log("ResizeObserver device-pixel-content-box not supported, falling back to window resize");
20228
+ window.addEventListener("resize", () => {
20229
+ this.needsResize = true;
20230
+ });
20231
+ }
17629
20232
  setInterval(() => {
17630
20233
  if (this.needsResize && !this._resizing) {
17631
20234
  this.needsResize = false;
@@ -17745,11 +20348,19 @@ class Engine {
17745
20348
  * @param {Array} options.position - Optional position offset [x, y, z]
17746
20349
  * @param {Array} options.rotation - Optional rotation offset [x, y, z] in radians
17747
20350
  * @param {number} options.scale - Optional uniform scale multiplier
20351
+ * @param {boolean} options.doubleSided - Optional: force all materials to be double-sided
17748
20352
  * @returns {Promise<Object>} Object containing { meshes, nodes, skins, animations }
17749
20353
  */
17750
20354
  async loadScene(url, options = {}) {
17751
20355
  const result = await loadGltf(this, url, options);
17752
20356
  const { meshes, nodes } = result;
20357
+ if (options.doubleSided) {
20358
+ for (const mesh of Object.values(meshes)) {
20359
+ if (mesh.material) {
20360
+ mesh.material.doubleSided = true;
20361
+ }
20362
+ }
20363
+ }
17753
20364
  for (const node of nodes) {
17754
20365
  if (!node.parent) {
17755
20366
  node.updateMatrix(null);
@@ -17769,6 +20380,23 @@ class Engine {
17769
20380
  [scl, scl, scl]
17770
20381
  );
17771
20382
  }
20383
+ let combinedBsphere = null;
20384
+ const hasAnySkin = Object.values(meshes).some((m) => m.hasSkin);
20385
+ if (hasAnySkin) {
20386
+ const allPositions = [];
20387
+ for (const mesh of Object.values(meshes)) {
20388
+ const positions = mesh.geometry?.attributes?.position;
20389
+ if (positions) {
20390
+ for (let i = 0; i < positions.length; i += 3) {
20391
+ allPositions.push(positions[i], positions[i + 1], positions[i + 2]);
20392
+ }
20393
+ }
20394
+ }
20395
+ if (allPositions.length > 0) {
20396
+ const { calculateBoundingSphere: calculateBoundingSphere2 } = await Promise.resolve().then(() => BoundingSphere);
20397
+ combinedBsphere = calculateBoundingSphere2(new Float32Array(allPositions));
20398
+ }
20399
+ }
17772
20400
  for (const [name, mesh] of Object.entries(meshes)) {
17773
20401
  let meshNode = null;
17774
20402
  if (mesh.nodeIndex !== null && mesh.nodeIndex !== void 0) {
@@ -17781,7 +20409,7 @@ class Engine {
17781
20409
  if (options.position || options.rotation || options.scale) {
17782
20410
  mat4.multiply(worldMatrix, rootTransform, worldMatrix);
17783
20411
  }
17784
- const localBsphere = mesh.geometry.getBoundingSphere?.();
20412
+ const localBsphere = hasAnySkin && combinedBsphere ? combinedBsphere : mesh.geometry.getBoundingSphere?.();
17785
20413
  let worldCenter = [0, 0, 0];
17786
20414
  let worldRadius = 1;
17787
20415
  if (localBsphere && localBsphere.radius > 0) {
@@ -17796,6 +20424,9 @@ class Engine {
17796
20424
  const scaleZ = Math.sqrt(worldMatrix[8] ** 2 + worldMatrix[9] ** 2 + worldMatrix[10] ** 2);
17797
20425
  worldRadius = localBsphere.radius * Math.max(scaleX, scaleY, scaleZ);
17798
20426
  }
20427
+ if (hasAnySkin && combinedBsphere) {
20428
+ mesh.combinedBsphere = combinedBsphere;
20429
+ }
17799
20430
  mesh.addInstance(worldCenter, worldRadius);
17800
20431
  mesh.updateInstance(0, worldMatrix);
17801
20432
  mesh.static = true;
@@ -17864,6 +20495,16 @@ class Engine {
17864
20495
  getEntity(id) {
17865
20496
  return this.entityManager.get(id);
17866
20497
  }
20498
+ /**
20499
+ * Invalidate occlusion culling data and reset warmup period.
20500
+ * Call this after scene loading or major camera teleportation to prevent
20501
+ * incorrect occlusion culling with stale depth buffer data.
20502
+ */
20503
+ invalidateOcclusionCulling() {
20504
+ if (this.renderer) {
20505
+ this.renderer.invalidateOcclusionCulling();
20506
+ }
20507
+ }
17867
20508
  async _create() {
17868
20509
  let camera = new Camera(this);
17869
20510
  camera.updateMatrix();
@@ -17886,6 +20527,8 @@ class Engine {
17886
20527
  }
17887
20528
  async _after_create() {
17888
20529
  this.renderer = await RenderGraph.create(this, this.environment, this.environmentEncoding);
20530
+ this.raycaster = new Raycaster(this);
20531
+ await this.raycaster.initialize();
17889
20532
  }
17890
20533
  _update(dt) {
17891
20534
  this._updateInput();
@@ -17941,7 +20584,7 @@ class Engine {
17941
20584
  this.guiCanvas.height = canvas.height;
17942
20585
  this.guiCtx.clearRect(0, 0, canvas.width, canvas.height);
17943
20586
  }
17944
- await this.renderer.resize(canvas.width, canvas.height);
20587
+ await this.renderer.resize(canvas.width, canvas.height, this.renderScale);
17945
20588
  this.resize();
17946
20589
  await new Promise((resolve) => setTimeout(resolve, 16));
17947
20590
  this._resizing = false;
@@ -18194,6 +20837,7 @@ exports.Material = Material;
18194
20837
  exports.Mesh = Mesh;
18195
20838
  exports.ParticleEmitter = ParticleEmitter;
18196
20839
  exports.ParticleSystem = ParticleSystem;
20840
+ exports.Raycaster = Raycaster;
18197
20841
  exports.RenderGraph = RenderGraph;
18198
20842
  exports.Texture = Texture;
18199
20843
  exports.fail = fail;