@downdraft/engine 0.3.0 → 0.3.1

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 (82) hide show
  1. package/app/src/renderer/bootstrap.ts +0 -6
  2. package/app/src/renderer/feature-log.ts +1 -9
  3. package/app/src/renderer/mcp-harness.ts +8 -11
  4. package/app/src/renderer/standard-automation-tools.ts +8 -145
  5. package/core/src/module/host.ts +3 -6
  6. package/core/src/module/index.ts +1 -1
  7. package/core/src/module/module.ts +4 -47
  8. package/core/src/physics/interface.ts +5 -0
  9. package/core/src/platform/render-surface.ts +13 -0
  10. package/core/src/render/canvas-resize-watcher.ts +1 -10
  11. package/core/src/render/game-renderer.ts +40 -18
  12. package/core/src/render/index.ts +0 -2
  13. package/core/src/render/input-manager.ts +7 -7
  14. package/core/src/render/renderer-module-host.ts +2 -4
  15. package/core/src/ui-font-scale.ts +5 -38
  16. package/core/src/worker/rpc.ts +65 -0
  17. package/dist/.dd-manifest.json +859 -884
  18. package/dist/app/src/renderer/bootstrap.js +0 -4
  19. package/dist/app/src/renderer/feature-log.js +1 -10
  20. package/dist/app/src/renderer/mcp-harness.js +8 -11
  21. package/dist/app/src/renderer/standard-automation-tools.js +6 -138
  22. package/dist/core/src/module/host.js +3 -6
  23. package/dist/core/src/render/canvas-resize-watcher.js +1 -10
  24. package/dist/core/src/render/game-renderer.js +44 -18
  25. package/dist/core/src/render/index.js +0 -1
  26. package/dist/core/src/render/input-manager.js +7 -7
  27. package/dist/core/src/render/renderer-module-host.js +2 -4
  28. package/dist/core/src/ui-font-scale.js +5 -38
  29. package/dist/core/src/worker/rpc.js +59 -0
  30. package/dist/libraries/blitz-ui/src/native-module.js +8 -1
  31. package/dist/libraries/devtools/src/native-providers.js +0 -2
  32. package/dist/libraries/entities/src/model-renderer.js +87 -27
  33. package/dist/libraries/entities/src/shaders/model.wgsl +13 -4
  34. package/dist/libraries/networking/src/index.js +1 -0
  35. package/dist/libraries/networking/src/ws-server.js +387 -0
  36. package/dist/libraries/physics-rapier/src/api.js +15 -0
  37. package/dist/libraries/physics-rapier/src/backend.js +15 -0
  38. package/dist/libraries/physics-rapier/src/rapier-backend.js +5 -0
  39. package/dist/libraries/postfx/src/post-process-stack.js +1 -3
  40. package/dist/libraries/sand/src/materials.js +2 -32
  41. package/dist/libraries/sand/src/sand-world.js +0 -3
  42. package/dist/modules/devtools/src/api.js +8 -23
  43. package/dist/modules/devtools/src/data-bridge.js +0 -11
  44. package/dist/modules/devtools/src/index.js +0 -6
  45. package/dist/modules/devtools/src/init.js +8 -42
  46. package/dist/modules/devtools/src/scene-inspector.js +1 -4
  47. package/dist/modules/devtools/src/worker-sync.js +3 -11
  48. package/dist/test/src/vision-test.js +1 -3
  49. package/libraries/blitz-ui/src/native-module.ts +8 -1
  50. package/libraries/devtools/src/native-providers.ts +0 -2
  51. package/libraries/entities/src/model-renderer.ts +88 -27
  52. package/libraries/entities/src/shaders/model.wgsl +13 -4
  53. package/libraries/networking/src/index.ts +4 -0
  54. package/libraries/networking/src/ws-server.ts +397 -0
  55. package/libraries/physics-rapier/src/api.ts +15 -0
  56. package/libraries/physics-rapier/src/backend.ts +15 -0
  57. package/libraries/physics-rapier/src/rapier-backend.ts +6 -0
  58. package/libraries/postfx/src/post-process-stack.ts +1 -2
  59. package/libraries/sand/src/materials.ts +2 -32
  60. package/libraries/sand/src/sand-world.ts +0 -4
  61. package/modules/devtools/README.md +1 -3
  62. package/modules/devtools/src/api.ts +10 -34
  63. package/modules/devtools/src/data-bridge.ts +0 -16
  64. package/modules/devtools/src/index.ts +1 -11
  65. package/modules/devtools/src/init.ts +8 -51
  66. package/modules/devtools/src/scene-inspector.ts +2 -6
  67. package/modules/devtools/src/types.ts +5 -90
  68. package/modules/devtools/src/worker-sync.ts +3 -13
  69. package/package.json +4 -5
  70. package/test/src/vision-test.ts +2 -4
  71. package/core/src/render/sab-canvas-overlay.ts +0 -122
  72. package/dist/core/src/render/sab-canvas-overlay.js +0 -83
  73. package/dist/modules/devtools/src/debug-overlay.js +0 -181
  74. package/dist/modules/devtools/src/doctor-panel.js +0 -163
  75. package/dist/modules/devtools/src/label-overlay.js +0 -129
  76. package/dist/modules/devtools/src/material-stats-panel.js +0 -171
  77. package/dist/modules/devtools/src/sim-stats-panel.js +0 -250
  78. package/modules/devtools/src/debug-overlay.ts +0 -238
  79. package/modules/devtools/src/doctor-panel.ts +0 -182
  80. package/modules/devtools/src/label-overlay.ts +0 -154
  81. package/modules/devtools/src/material-stats-panel.ts +0 -188
  82. package/modules/devtools/src/sim-stats-panel.ts +0 -272
@@ -515,8 +515,6 @@ async function collectWorkers(ctx) {
515
515
  kind: "kv",
516
516
  name: `Worker manifest (v${m.manifestVersion ?? "?"})`,
517
517
  rows: [
518
- kv("Panels", (m.panels ?? []).map((p) => p.id ?? p.name ?? "?").join(", ") || "(none)"),
519
- kv("Toggles", (m.toggles ?? []).map((t) => t.id ?? "?").join(", ") || "(none)"),
520
518
  kv("Data feeds", (m.dataFeeds ?? []).map((f) => `${f.name}@${f.writeRateHz}hz`).join(", ") || "(none)"),
521
519
  kv("Commands", (m.commands ?? []).join(", ") || "(none)"),
522
520
  ],
@@ -9,7 +9,7 @@
9
9
  // — no per-draw bind-group creation or texture bind-group churn.
10
10
  // ============================================================================
11
11
  import { calculateViewProj, createLogger, createValidatedShaderModule, DEPTH_FORMAT, MSAA_SAMPLE_COUNT } from "@downdraft/engine";
12
- const MODEL_WGSL = "struct Uniforms {\n viewProj: mat4x4<f32>,\n cameraPos: vec3<f32>,\n time: f32,\n modelPos: vec3<f32>,\n modelScale: vec3<f32>,\n modelRot: vec4<f32>,\n materialIndex: u32,\n lightDirX: f32,\n lightDirY: f32,\n lightDirZ: f32,\n lightAmbient: f32,\n lightIntensity: f32,\n // Per-draw highlight mode: 0 = normal shading, 1 = ghost hologram (cyan,\n // used for the physgun's no-collision grab), 2 = hover outline (drawn by a\n // separate inverted-hull pipeline — see vs_outline / fs_outline).\n // Values >= 3 encode per-prop material overrides (spawn settings):\n // combined = u32(highlight) - 3\n // shaderMode = combined & 3 (0=Standard 1=Toon 2=Hologram 3=Outline)\n // texMode = combined >> 2 (0=Default 1=Wireframe 2=Checker)\n highlight: f32,\n // Inverted-hull outline parameters (used by vs_outline / fs_outline only).\n outlineWidth: f32,\n outlineColor: vec3<f32>,\n};\n\n@group(0) @binding(0) var<uniform> uniforms: Uniforms;\n\n// Skin matrices for skinned meshes (@group(1)). A dynamic storage buffer of\n// mat4x4<f32>, one per bone. Updated per-frame by the renderer via\n// updateSkinMatrices(). Only bound when drawing skinned meshes.\n@group(1) @binding(0) var<storage, read> skinMatrices: array<mat4x4<f32>>;\n\n// Frame-global lighting (@group(2)): sun color, hemisphere ambient, point lights.\n// Games that want colored lighting provide a compatible uniform buffer; the\n// default bind group supplies a neutral white sun + no point lights so existing\n// consumers see no visual change.\nstruct FrameLighting {\n sunDir: vec3<f32>,\n ambientIntensity: f32,\n sunColor: vec3<f32>,\n pointLightCount: u32,\n skyAmbient: vec3<f32>,\n _pad0: u32,\n groundAmbient: vec3<f32>,\n _pad1: u32,\n pointLights: array<vec4<f32>, 16>,\n};\n@group(2) @binding(0) var<uniform> frameLighting: FrameLighting;\n\n// Bindless material binding model (@group(3)):\n// binding 0: material SSBO (read-only storage)\n// bindings 1..8: texture_2d_array pages (rgba8unorm color textures)\n// binding 9: shared sampler (repeat)\n// binding 10: shared sampler (clamp)\nstruct BindlessMaterial {\n baseColor: vec4<f32>,\n roughness: f32,\n metallic: f32,\n emissiveIntensity: f32,\n hasTexTransform: f32,\n albedoTex: u32,\n normalTex: u32,\n metallicRoughnessTex: u32,\n aoEmissiveTex: u32,\n texOffset: vec2<f32>,\n texScale: vec2<f32>,\n texRotation: f32,\n _pad0: f32,\n _pad1: f32,\n _pad2: f32,\n};\n\n@group(3) @binding(0) var<storage, read> bindlessMaterials: array<BindlessMaterial>;\n@group(3) @binding(1) var albedoArray0: texture_2d_array<f32>;\n@group(3) @binding(2) var albedoArray1: texture_2d_array<f32>;\n@group(3) @binding(3) var albedoArray2: texture_2d_array<f32>;\n@group(3) @binding(4) var albedoArray3: texture_2d_array<f32>;\n@group(3) @binding(5) var albedoArray4: texture_2d_array<f32>;\n@group(3) @binding(6) var albedoArray5: texture_2d_array<f32>;\n@group(3) @binding(7) var albedoArray6: texture_2d_array<f32>;\n@group(3) @binding(8) var albedoArray7: texture_2d_array<f32>;\n@group(3) @binding(9) var bindlessSamplerRepeat: sampler;\n@group(3) @binding(10) var bindlessSamplerClamp: sampler;\n\nfn unpackArrayIndex(handle: u32) -> u32 { return (handle >> 16u) & 0xFFFFu; }\nfn unpackLayerIndex(handle: u32) -> u32 { return handle & 0xFFFFu; }\n\nfn sampleBindlessArray(arr: u32, uv: vec2<f32>, layer: u32) -> vec4<f32> {\n // textureSample (auto-LOD) so mipmapped registrations actually filter;\n // single-mip textures behave identically to textureSampleLevel(..., 0.0).\n switch (arr) {\n case 0u: { return textureSample(albedoArray0, bindlessSamplerRepeat, uv, layer); }\n case 1u: { return textureSample(albedoArray1, bindlessSamplerRepeat, uv, layer); }\n case 2u: { return textureSample(albedoArray2, bindlessSamplerRepeat, uv, layer); }\n case 3u: { return textureSample(albedoArray3, bindlessSamplerRepeat, uv, layer); }\n case 4u: { return textureSample(albedoArray4, bindlessSamplerRepeat, uv, layer); }\n case 5u: { return textureSample(albedoArray5, bindlessSamplerRepeat, uv, layer); }\n case 6u: { return textureSample(albedoArray6, bindlessSamplerRepeat, uv, layer); }\n case 7u: { return textureSample(albedoArray7, bindlessSamplerRepeat, uv, layer); }\n default: { return vec4<f32>(1.0, 1.0, 1.0, 1.0); }\n }\n}\n\nstruct VertexInput {\n @location(0) position: vec3<f32>,\n @location(1) normal: vec3<f32>,\n @location(2) uv: vec2<f32>,\n @location(3) color: vec3<f32>,\n};\n\n// Skinned vertex input — extends VertexInput with joints (4 bone indices,\n// packed as vec4<u32>) and weights (4 normalized bone weights). Lives in a\n// second vertex buffer slot (buffer index 1).\nstruct SkinnedVertexInput {\n @location(0) position: vec3<f32>,\n @location(1) normal: vec3<f32>,\n @location(2) uv: vec2<f32>,\n @location(3) color: vec3<f32>,\n @location(4) joints: vec4<u32>,\n @location(5) weights: vec4<f32>,\n};\n\nstruct VertexOutput {\n @builtin(position) clipPos: vec4<f32>,\n @location(0) worldPos: vec3<f32>,\n @location(1) normal: vec3<f32>,\n @location(2) uv: vec2<f32>,\n @location(3) color: vec3<f32>,\n};\n\n// Build a tangent basis from the geometric normal. Uses a reference vector\n// instead of screen-space derivatives (dpdx/dpdy) which can produce NaN on\n// some implementations / degenerate UVs. The tangent direction is arbitrary\n// (determined by the reference vector) but is sufficient for normal mapping\n// on meshes without explicit tangent attributes.\nfn buildTangentBasis(N: vec3<f32>, worldPos: vec4<f32>, uv: vec2<f32>) -> mat3x3<f32> {\n var refVec = vec3<f32>(0.0, 1.0, 0.0);\n if (abs(N.y) >= 0.99) {\n refVec = vec3<f32>(1.0, 0.0, 0.0);\n }\n let T = normalize(cross(refVec, N));\n let B = cross(N, T);\n return mat3x3<f32>(T, B, N);\n}\n\n// Sample the normal map and perturb the geometric normal. Returns the\n// perturbed world-space normal. If the material has no normal map (normalTex\n// points at the default white texture), the sampled tangent-space normal is\n// (0.5, 0.5, 1.0) → unpacked to (0, 0, 1) → no perturbation.\nfn perturbNormal(N: vec3<f32>, worldPos: vec4<f32>, uv: vec2<f32>, normalTexHandle: u32) -> vec3<f32> {\n let arr = unpackArrayIndex(normalTexHandle);\n let layer = unpackLayerIndex(normalTexHandle);\n let sampled = sampleBindlessArray(arr, uv, layer);\n let tangentNormal = sampled.xyz * 2.0 - 1.0;\n let basis = buildTangentBasis(N, worldPos, uv);\n return normalize(basis * tangentNormal);\n}\n\nfn qrotate(q: vec4<f32>, v: vec3<f32>) -> vec3<f32> {\n return v + 2.0 * cross(q.xyz, cross(q.xyz, v) + q.w * v);\n}\n\n// Accumulate the 4-bone skinning matrix: sum(weights[i] * skinMatrices[joint[i]]).\n// If all weights are zero (vertex has no bone assignment), return identity so\n// the vertex renders at its baked position instead of collapsing to the origin.\nfn skinMatrix(j: vec4<u32>, w: vec4<f32>) -> mat4x4<f32> {\n let m = skinMatrices[j.x] * w.x\n + skinMatrices[j.y] * w.y\n + skinMatrices[j.z] * w.z\n + skinMatrices[j.w] * w.w;\n let wsum = w.x + w.y + w.z + w.w;\n if (wsum < 0.001) {\n return mat4x4<f32>(\n vec4(1.0, 0.0, 0.0, 0.0),\n vec4(0.0, 1.0, 0.0, 0.0),\n vec4(0.0, 0.0, 1.0, 0.0),\n vec4(0.0, 0.0, 0.0, 1.0),\n );\n }\n return m;\n}\n\n@vertex\nfn vs_main(input: VertexInput) -> VertexOutput {\n var output: VertexOutput;\n let scaled = input.position * uniforms.modelScale;\n let rotated = qrotate(uniforms.modelRot, scaled);\n let worldPos = rotated + uniforms.modelPos;\n output.worldPos = worldPos;\n output.clipPos = uniforms.viewProj * vec4<f32>(worldPos, 1.0);\n output.normal = normalize(qrotate(uniforms.modelRot, input.normal));\n output.uv = input.uv;\n output.color = input.color;\n return output;\n}\n\n// Skinned vertex entry point. Applies linear blend skinning in model space\n// (skinMatrices already encode bone-world * inverseBind), then the same\n// model transform (scale/rot/pos) as vs_main.\n@vertex\nfn vs_skinned(input: SkinnedVertexInput) -> VertexOutput {\n var output: VertexOutput;\n let sm = skinMatrix(input.joints, input.weights);\n let skinnedPos = (sm * vec4<f32>(input.position, 1.0)).xyz;\n let skinnedNormal = (sm * vec4<f32>(input.normal, 0.0)).xyz;\n\n let scaled = skinnedPos * uniforms.modelScale;\n let rotated = qrotate(uniforms.modelRot, scaled);\n let worldPos = rotated + uniforms.modelPos;\n output.worldPos = worldPos;\n output.clipPos = uniforms.viewProj * vec4<f32>(worldPos, 1.0);\n output.normal = normalize(qrotate(uniforms.modelRot, normalize(skinnedNormal)));\n output.uv = input.uv;\n output.color = input.color;\n return output;\n}\n\n@fragment\nfn fs_main(input: VertexOutput) -> @location(0) vec4<f32> {\n let geometricN = normalize(input.normal);\n\n // Bindless material lookup (needed for normal map handle + albedo).\n let m = bindlessMaterials[uniforms.materialIndex];\n\n // Perturb the normal using the material's normal map (if any). Materials\n // without a normal map use the default flat-normal texture (0.5,0.5,1.0)\n // → unpacked to (0,0,1) → no perturbation.\n let N = perturbNormal(geometricN, vec4<f32>(input.worldPos, 1.0), input.uv, m.normalTex);\n\n // Decode per-prop material overrides (highlight >= 3).\n var shaderMode = 0u;\n var texMode = 0u;\n if (uniforms.highlight >= 3.0) {\n let combined = u32(uniforms.highlight) - 3u;\n shaderMode = combined & 3u;\n texMode = combined >> 2u;\n }\n\n // Colored directional sun light — matches the procedural cube/sphere shader.\n let sunDir = normalize(frameLighting.sunDir);\n var ndotl = max(dot(N, sunDir), 0.0);\n // Toon shader override: quantize the sun term into discrete bands.\n if (shaderMode == 1u) {\n ndotl = floor(ndotl * 3.0 + 0.5) / 3.0;\n ndotl = max(ndotl, 0.15);\n }\n let sunDiffuse = ndotl * frameLighting.sunColor;\n\n // Hemisphere ambient: blend sky/ground based on normal.y (same as cube shader).\n let hemiMix = N.y * 0.5 + 0.5;\n let hemisphere = mix(frameLighting.groundAmbient, frameLighting.skyAmbient, hemiMix);\n let ambient = hemisphere * frameLighting.ambientIntensity;\n\n // Bindless albedo sample: index the material SSBO by materialIndex, then\n // sample the texture_2d_array page/layer the material points at.\n let arr = unpackArrayIndex(m.albedoTex);\n let layer = unpackLayerIndex(m.albedoTex);\n let texColor = sampleBindlessArray(arr, input.uv, layer);\n\n // All inputs are sRGB: texture (rgba8unorm), vertex color (white), and\n // baseColor (parser converts FBX linear DiffuseColor to sRGB). The swapchain\n // is non-sRGB (bgra8unorm), so output sRGB directly.\n var baseColor = texColor.rgb * input.color * m.baseColor.rgb;\n\n // ── Texture override modes ──\n // Wireframe: darken albedo, UV-grid lines overlaid after lighting.\n // Checker: two-tone checkerboard over the UVs.\n var texLine = 0.0;\n if (texMode == 1u) {\n let g = fract(input.uv * 6.0);\n let lx = min(g.x, 1.0 - g.x);\n let ly = min(g.y, 1.0 - g.y);\n texLine = 1.0 - smoothstep(0.0, 0.08, min(lx, ly));\n baseColor = baseColor * 0.15;\n } else if (texMode == 2u) {\n let cell = floor(input.uv * 8.0);\n let check = (cell.x + cell.y) - floor((cell.x + cell.y) * 0.5) * 2.0;\n baseColor = mix(baseColor * 0.35, baseColor * 1.4 + vec3<f32>(0.08), check);\n }\n\n // Channel-inspection modes — bypass lighting entirely for material review.\n // 3 = perturbed normals (normal-map contribution visible), 4 = unlit albedo\n // (texture/baseColor only), 5 = geometric normals (no normal map).\n if (texMode == 3u) { return vec4<f32>(N * 0.5 + vec3<f32>(0.5), 1.0); }\n if (texMode == 4u) { return vec4<f32>(baseColor, 1.0); }\n if (texMode == 5u) { return vec4<f32>(geometricN * 0.5 + vec3<f32>(0.5), 1.0); }\n\n var litColor = baseColor * (ambient + sunDiffuse);\n\n // Point lights (up to 8). Each light is 2 vec4s: (pos.xyz, radius) + (color.rgb, intensity).\n let plCount = frameLighting.pointLightCount;\n for (var i = 0u; i < plCount; i++) {\n let pl0 = frameLighting.pointLights[i * 2u];\n let pl1 = frameLighting.pointLights[i * 2u + 1u];\n let plPos = pl0.xyz;\n let plRadius = pl0.w;\n let plColor = pl1.xyz;\n let plIntensity = pl1.w;\n let L = plPos - input.worldPos;\n let dist = length(L);\n if (dist < plRadius) {\n let atten = 1.0 / (1.0 + dist * dist / (plRadius * plRadius));\n let ndotl_pl = max(dot(N, normalize(L)), 0.0);\n litColor += baseColor * plColor * plIntensity * atten * ndotl_pl;\n }\n }\n\n // Wireframe overlay: paint the UV-grid lines in a bright tint.\n if (texLine > 0.0) {\n litColor = mix(litColor, vec3<f32>(0.55, 0.95, 1.0), texLine * 0.9);\n }\n\n // Distance fog — anchored to the camera→model distance so it scales with\n // the subject instead of a fixed 400m absolute (a 200m model viewed from\n // 500m would otherwise fog to the ambient color entirely). Small models\n // near the camera keep the original ~400m reach.\n let modelDist = length(uniforms.cameraPos - uniforms.modelPos);\n let fogStart = max(modelDist * 1.5, 60.0);\n let fogEnd = max(modelDist * 4.0, 400.0);\n let dist = length(uniforms.cameraPos - input.worldPos);\n let fog = clamp(1.0 - (dist - fogStart) / (fogEnd - fogStart), 0.0, 1.0);\n var outColor = mix(frameLighting.skyAmbient, litColor, fog);\n\n // Outline shader override (shaderMode == 3): darken the body and add a\n // bright fresnel rim so the prop reads with a bold cel edge.\n if (shaderMode == 3u) {\n let viewDir = normalize(uniforms.cameraPos - input.worldPos);\n let fresnel = pow(1.0 - max(dot(N, viewDir), 0.0), 3.0);\n outColor = outColor * 0.7 + vec3<f32>(1.0, 0.95, 0.6) * smoothstep(0.35, 0.9, fresnel);\n }\n\n // Hologram shader override (shaderMode == 2): fresnel rim + scanline pulse\n // tinted toward the prop's own base color. Opaque output (the pipeline has\n // no blend state) but reads as a hologram.\n if (shaderMode == 2u) {\n let viewDir = normalize(uniforms.cameraPos - input.worldPos);\n let fresnel = pow(1.0 - max(dot(N, viewDir), 0.0), 2.5);\n let pulse = 0.65 + 0.35 * sin(uniforms.time * 5.0 + input.worldPos.y * 3.0);\n let scan = 0.5 + 0.5 * sin((input.worldPos.y + uniforms.time * 2.0) * 20.0);\n let holoBase = baseColor * vec3<f32>(0.3, 0.9, 1.1) + vec3<f32>(0.05, 0.2, 0.25);\n let holoRim = vec3<f32>(0.7, 1.0, 1.0);\n var holoCol = mix(holoBase, holoRim, fresnel) * pulse;\n holoCol = holoCol + holoRim * scan * 0.15 * fresnel;\n return vec4<f32>(holoCol, 1.0);\n }\n\n // Ghost hologram override (highlight == 1): render the prop as a\n // translucent-looking cyan shell with a fresnel rim and a vertical\n // scanline pulse so it's visually distinct from normally-grabbed (solid)\n // props. Opaque output (the pipeline has no blend state) but reads as a\n // hologram. Hover outline (highlight == 2) is handled by a separate\n // inverted-hull draw — see vs_outline / fs_outline below.\n if (uniforms.highlight > 0.5 && uniforms.highlight < 1.5) {\n let viewDir = normalize(uniforms.cameraPos - input.worldPos);\n let fresnel = pow(1.0 - max(dot(N, viewDir), 0.0), 2.5);\n let pulse = 0.65 + 0.35 * sin(uniforms.time * 5.0 + input.worldPos.y * 3.0);\n let scan = 0.5 + 0.5 * sin((input.worldPos.y + uniforms.time * 2.0) * 20.0);\n let ghostBase = vec3<f32>(0.15, 0.75, 0.95);\n let ghostRim = vec3<f32>(0.7, 1.0, 1.0);\n var ghostCol = mix(ghostBase, ghostRim, fresnel) * pulse;\n ghostCol = ghostCol + ghostRim * scan * 0.15 * fresnel;\n return vec4<f32>(ghostCol, 1.0);\n }\n\n return vec4<f32>(outColor, 1.0);\n}\n\n// ── Mask entry point for post-process outline ──\n// Renders the model as solid white to a mask texture. Uses the same\n// vertex shader as the normal render (vs_main / vs_skinned).\n@fragment\nfn fs_mask() -> @location(0) vec4f {\n return vec4f(1.0, 1.0, 1.0, 1.0);\n}\n";
12
+ const MODEL_WGSL = "struct Uniforms {\n viewProj: mat4x4<f32>,\n cameraPos: vec3<f32>,\n time: f32,\n modelPos: vec3<f32>,\n modelScale: vec3<f32>,\n modelRot: vec4<f32>,\n materialIndex: u32,\n lightDirX: f32,\n lightDirY: f32,\n lightDirZ: f32,\n lightAmbient: f32,\n lightIntensity: f32,\n // Per-draw highlight mode: 0 = normal shading, 1 = ghost hologram (cyan,\n // used for the physgun's no-collision grab), 2 = hover outline (drawn by a\n // separate inverted-hull pipeline — see vs_outline / fs_outline).\n // Values >= 3 encode per-prop material overrides (spawn settings):\n // combined = u32(highlight) - 3\n // shaderMode = combined & 3 (0=Standard 1=Toon 2=Hologram 3=Outline)\n // texMode = combined >> 2 (0=Default 1=Wireframe 2=Checker)\n highlight: f32,\n // Inverted-hull outline parameters (used by vs_outline / fs_outline only).\n outlineWidth: f32,\n outlineColor: vec3<f32>,\n // Base index of this draw's bone region inside the shared skinMatrices\n // storage buffer (group(1)). Written by ModelRenderer from the node's\n // reserved region — lets multiple skinned models animate independently.\n // Only read by vs_skinned.\n boneBase: u32,\n _padSkin0: u32,\n _padSkin1: u32,\n _padSkin2: u32,\n};\n\n@group(0) @binding(0) var<uniform> uniforms: Uniforms;\n\n// Skin matrices for skinned meshes (@group(1)). A dynamic storage buffer of\n// mat4x4<f32>, one per bone. Updated per-frame by the renderer via\n// updateSkinMatrices(). Only bound when drawing skinned meshes.\n@group(1) @binding(0) var<storage, read> skinMatrices: array<mat4x4<f32>>;\n\n// Frame-global lighting (@group(2)): sun color, hemisphere ambient, point lights.\n// Games that want colored lighting provide a compatible uniform buffer; the\n// default bind group supplies a neutral white sun + no point lights so existing\n// consumers see no visual change.\nstruct FrameLighting {\n sunDir: vec3<f32>,\n ambientIntensity: f32,\n sunColor: vec3<f32>,\n pointLightCount: u32,\n skyAmbient: vec3<f32>,\n _pad0: u32,\n groundAmbient: vec3<f32>,\n _pad1: u32,\n pointLights: array<vec4<f32>, 16>,\n};\n@group(2) @binding(0) var<uniform> frameLighting: FrameLighting;\n\n// Bindless material binding model (@group(3)):\n// binding 0: material SSBO (read-only storage)\n// bindings 1..8: texture_2d_array pages (rgba8unorm color textures)\n// binding 9: shared sampler (repeat)\n// binding 10: shared sampler (clamp)\nstruct BindlessMaterial {\n baseColor: vec4<f32>,\n roughness: f32,\n metallic: f32,\n emissiveIntensity: f32,\n hasTexTransform: f32,\n albedoTex: u32,\n normalTex: u32,\n metallicRoughnessTex: u32,\n aoEmissiveTex: u32,\n texOffset: vec2<f32>,\n texScale: vec2<f32>,\n texRotation: f32,\n _pad0: f32,\n _pad1: f32,\n _pad2: f32,\n};\n\n@group(3) @binding(0) var<storage, read> bindlessMaterials: array<BindlessMaterial>;\n@group(3) @binding(1) var albedoArray0: texture_2d_array<f32>;\n@group(3) @binding(2) var albedoArray1: texture_2d_array<f32>;\n@group(3) @binding(3) var albedoArray2: texture_2d_array<f32>;\n@group(3) @binding(4) var albedoArray3: texture_2d_array<f32>;\n@group(3) @binding(5) var albedoArray4: texture_2d_array<f32>;\n@group(3) @binding(6) var albedoArray5: texture_2d_array<f32>;\n@group(3) @binding(7) var albedoArray6: texture_2d_array<f32>;\n@group(3) @binding(8) var albedoArray7: texture_2d_array<f32>;\n@group(3) @binding(9) var bindlessSamplerRepeat: sampler;\n@group(3) @binding(10) var bindlessSamplerClamp: sampler;\n\nfn unpackArrayIndex(handle: u32) -> u32 { return (handle >> 16u) & 0xFFFFu; }\nfn unpackLayerIndex(handle: u32) -> u32 { return handle & 0xFFFFu; }\n\nfn sampleBindlessArray(arr: u32, uv: vec2<f32>, layer: u32) -> vec4<f32> {\n // textureSample (auto-LOD) so mipmapped registrations actually filter;\n // single-mip textures behave identically to textureSampleLevel(..., 0.0).\n switch (arr) {\n case 0u: { return textureSample(albedoArray0, bindlessSamplerRepeat, uv, layer); }\n case 1u: { return textureSample(albedoArray1, bindlessSamplerRepeat, uv, layer); }\n case 2u: { return textureSample(albedoArray2, bindlessSamplerRepeat, uv, layer); }\n case 3u: { return textureSample(albedoArray3, bindlessSamplerRepeat, uv, layer); }\n case 4u: { return textureSample(albedoArray4, bindlessSamplerRepeat, uv, layer); }\n case 5u: { return textureSample(albedoArray5, bindlessSamplerRepeat, uv, layer); }\n case 6u: { return textureSample(albedoArray6, bindlessSamplerRepeat, uv, layer); }\n case 7u: { return textureSample(albedoArray7, bindlessSamplerRepeat, uv, layer); }\n default: { return vec4<f32>(1.0, 1.0, 1.0, 1.0); }\n }\n}\n\nstruct VertexInput {\n @location(0) position: vec3<f32>,\n @location(1) normal: vec3<f32>,\n @location(2) uv: vec2<f32>,\n @location(3) color: vec3<f32>,\n};\n\n// Skinned vertex input — extends VertexInput with joints (4 bone indices,\n// packed as vec4<u32>) and weights (4 normalized bone weights). Lives in a\n// second vertex buffer slot (buffer index 1).\nstruct SkinnedVertexInput {\n @location(0) position: vec3<f32>,\n @location(1) normal: vec3<f32>,\n @location(2) uv: vec2<f32>,\n @location(3) color: vec3<f32>,\n @location(4) joints: vec4<u32>,\n @location(5) weights: vec4<f32>,\n};\n\nstruct VertexOutput {\n @builtin(position) clipPos: vec4<f32>,\n @location(0) worldPos: vec3<f32>,\n @location(1) normal: vec3<f32>,\n @location(2) uv: vec2<f32>,\n @location(3) color: vec3<f32>,\n};\n\n// Build a tangent basis from the geometric normal. Uses a reference vector\n// instead of screen-space derivatives (dpdx/dpdy) which can produce NaN on\n// some implementations / degenerate UVs. The tangent direction is arbitrary\n// (determined by the reference vector) but is sufficient for normal mapping\n// on meshes without explicit tangent attributes.\nfn buildTangentBasis(N: vec3<f32>, worldPos: vec4<f32>, uv: vec2<f32>) -> mat3x3<f32> {\n var refVec = vec3<f32>(0.0, 1.0, 0.0);\n if (abs(N.y) >= 0.99) {\n refVec = vec3<f32>(1.0, 0.0, 0.0);\n }\n let T = normalize(cross(refVec, N));\n let B = cross(N, T);\n return mat3x3<f32>(T, B, N);\n}\n\n// Sample the normal map and perturb the geometric normal. Returns the\n// perturbed world-space normal. If the material has no normal map (normalTex\n// points at the default white texture), the sampled tangent-space normal is\n// (0.5, 0.5, 1.0) → unpacked to (0, 0, 1) → no perturbation.\nfn perturbNormal(N: vec3<f32>, worldPos: vec4<f32>, uv: vec2<f32>, normalTexHandle: u32) -> vec3<f32> {\n let arr = unpackArrayIndex(normalTexHandle);\n let layer = unpackLayerIndex(normalTexHandle);\n let sampled = sampleBindlessArray(arr, uv, layer);\n let tangentNormal = sampled.xyz * 2.0 - 1.0;\n let basis = buildTangentBasis(N, worldPos, uv);\n return normalize(basis * tangentNormal);\n}\n\nfn qrotate(q: vec4<f32>, v: vec3<f32>) -> vec3<f32> {\n return v + 2.0 * cross(q.xyz, cross(q.xyz, v) + q.w * v);\n}\n\n// Accumulate the 4-bone skinning matrix: sum(weights[i] * skinMatrices[joint[i]]).\n// If all weights are zero (vertex has no bone assignment), return identity so\n// the vertex renders at its baked position instead of collapsing to the origin.\nfn skinMatrix(j: vec4<u32>, w: vec4<f32>) -> mat4x4<f32> {\n let base = uniforms.boneBase;\n let m = skinMatrices[base + j.x] * w.x\n + skinMatrices[base + j.y] * w.y\n + skinMatrices[base + j.z] * w.z\n + skinMatrices[base + j.w] * w.w;\n let wsum = w.x + w.y + w.z + w.w;\n if (wsum < 0.001) {\n return mat4x4<f32>(\n vec4(1.0, 0.0, 0.0, 0.0),\n vec4(0.0, 1.0, 0.0, 0.0),\n vec4(0.0, 0.0, 1.0, 0.0),\n vec4(0.0, 0.0, 0.0, 1.0),\n );\n }\n return m;\n}\n\n@vertex\nfn vs_main(input: VertexInput) -> VertexOutput {\n var output: VertexOutput;\n let scaled = input.position * uniforms.modelScale;\n let rotated = qrotate(uniforms.modelRot, scaled);\n let worldPos = rotated + uniforms.modelPos;\n output.worldPos = worldPos;\n output.clipPos = uniforms.viewProj * vec4<f32>(worldPos, 1.0);\n output.normal = normalize(qrotate(uniforms.modelRot, input.normal));\n output.uv = input.uv;\n output.color = input.color;\n return output;\n}\n\n// Skinned vertex entry point. Applies linear blend skinning in model space\n// (skinMatrices already encode bone-world * inverseBind), then the same\n// model transform (scale/rot/pos) as vs_main.\n@vertex\nfn vs_skinned(input: SkinnedVertexInput) -> VertexOutput {\n var output: VertexOutput;\n let sm = skinMatrix(input.joints, input.weights);\n let skinnedPos = (sm * vec4<f32>(input.position, 1.0)).xyz;\n let skinnedNormal = (sm * vec4<f32>(input.normal, 0.0)).xyz;\n\n let scaled = skinnedPos * uniforms.modelScale;\n let rotated = qrotate(uniforms.modelRot, scaled);\n let worldPos = rotated + uniforms.modelPos;\n output.worldPos = worldPos;\n output.clipPos = uniforms.viewProj * vec4<f32>(worldPos, 1.0);\n output.normal = normalize(qrotate(uniforms.modelRot, normalize(skinnedNormal)));\n output.uv = input.uv;\n output.color = input.color;\n return output;\n}\n\n@fragment\nfn fs_main(input: VertexOutput) -> @location(0) vec4<f32> {\n let geometricN = normalize(input.normal);\n\n // Bindless material lookup (needed for normal map handle + albedo).\n let m = bindlessMaterials[uniforms.materialIndex];\n\n // Perturb the normal using the material's normal map (if any). Materials\n // without a normal map use the default flat-normal texture (0.5,0.5,1.0)\n // → unpacked to (0,0,1) → no perturbation.\n let N = perturbNormal(geometricN, vec4<f32>(input.worldPos, 1.0), input.uv, m.normalTex);\n\n // Decode per-prop material overrides (highlight >= 3).\n var shaderMode = 0u;\n var texMode = 0u;\n if (uniforms.highlight >= 3.0) {\n let combined = u32(uniforms.highlight) - 3u;\n shaderMode = combined & 3u;\n texMode = combined >> 2u;\n }\n\n // Colored directional sun light — matches the procedural cube/sphere shader.\n let sunDir = normalize(frameLighting.sunDir);\n var ndotl = max(dot(N, sunDir), 0.0);\n // Toon shader override: quantize the sun term into discrete bands.\n if (shaderMode == 1u) {\n ndotl = floor(ndotl * 3.0 + 0.5) / 3.0;\n ndotl = max(ndotl, 0.15);\n }\n let sunDiffuse = ndotl * frameLighting.sunColor;\n\n // Hemisphere ambient: blend sky/ground based on normal.y (same as cube shader).\n let hemiMix = N.y * 0.5 + 0.5;\n let hemisphere = mix(frameLighting.groundAmbient, frameLighting.skyAmbient, hemiMix);\n let ambient = hemisphere * frameLighting.ambientIntensity;\n\n // Bindless albedo sample: index the material SSBO by materialIndex, then\n // sample the texture_2d_array page/layer the material points at.\n let arr = unpackArrayIndex(m.albedoTex);\n let layer = unpackLayerIndex(m.albedoTex);\n let texColor = sampleBindlessArray(arr, input.uv, layer);\n\n // All inputs are sRGB: texture (rgba8unorm), vertex color (white), and\n // baseColor (parser converts FBX linear DiffuseColor to sRGB). The swapchain\n // is non-sRGB (bgra8unorm), so output sRGB directly.\n var baseColor = texColor.rgb * input.color * m.baseColor.rgb;\n\n // ── Texture override modes ──\n // Wireframe: darken albedo, UV-grid lines overlaid after lighting.\n // Checker: two-tone checkerboard over the UVs.\n var texLine = 0.0;\n if (texMode == 1u) {\n let g = fract(input.uv * 6.0);\n let lx = min(g.x, 1.0 - g.x);\n let ly = min(g.y, 1.0 - g.y);\n texLine = 1.0 - smoothstep(0.0, 0.08, min(lx, ly));\n baseColor = baseColor * 0.15;\n } else if (texMode == 2u) {\n let cell = floor(input.uv * 8.0);\n let check = (cell.x + cell.y) - floor((cell.x + cell.y) * 0.5) * 2.0;\n baseColor = mix(baseColor * 0.35, baseColor * 1.4 + vec3<f32>(0.08), check);\n }\n\n // Channel-inspection modes — bypass lighting entirely for material review.\n // 3 = perturbed normals (normal-map contribution visible), 4 = unlit albedo\n // (texture/baseColor only), 5 = geometric normals (no normal map).\n if (texMode == 3u) { return vec4<f32>(N * 0.5 + vec3<f32>(0.5), 1.0); }\n if (texMode == 4u) { return vec4<f32>(baseColor, 1.0); }\n if (texMode == 5u) { return vec4<f32>(geometricN * 0.5 + vec3<f32>(0.5), 1.0); }\n\n var litColor = baseColor * (ambient + sunDiffuse);\n\n // Point lights (up to 8). Each light is 2 vec4s: (pos.xyz, radius) + (color.rgb, intensity).\n let plCount = frameLighting.pointLightCount;\n for (var i = 0u; i < plCount; i++) {\n let pl0 = frameLighting.pointLights[i * 2u];\n let pl1 = frameLighting.pointLights[i * 2u + 1u];\n let plPos = pl0.xyz;\n let plRadius = pl0.w;\n let plColor = pl1.xyz;\n let plIntensity = pl1.w;\n let L = plPos - input.worldPos;\n let dist = length(L);\n if (dist < plRadius) {\n let atten = 1.0 / (1.0 + dist * dist / (plRadius * plRadius));\n let ndotl_pl = max(dot(N, normalize(L)), 0.0);\n litColor += baseColor * plColor * plIntensity * atten * ndotl_pl;\n }\n }\n\n // Wireframe overlay: paint the UV-grid lines in a bright tint.\n if (texLine > 0.0) {\n litColor = mix(litColor, vec3<f32>(0.55, 0.95, 1.0), texLine * 0.9);\n }\n\n // Distance fog — anchored to the camera→model distance so it scales with\n // the subject instead of a fixed 400m absolute (a 200m model viewed from\n // 500m would otherwise fog to the ambient color entirely). Small models\n // near the camera keep the original ~400m reach.\n let modelDist = length(uniforms.cameraPos - uniforms.modelPos);\n let fogStart = max(modelDist * 1.5, 60.0);\n let fogEnd = max(modelDist * 4.0, 400.0);\n let dist = length(uniforms.cameraPos - input.worldPos);\n let fog = clamp(1.0 - (dist - fogStart) / (fogEnd - fogStart), 0.0, 1.0);\n var outColor = mix(frameLighting.skyAmbient, litColor, fog);\n\n // Outline shader override (shaderMode == 3): darken the body and add a\n // bright fresnel rim so the prop reads with a bold cel edge.\n if (shaderMode == 3u) {\n let viewDir = normalize(uniforms.cameraPos - input.worldPos);\n let fresnel = pow(1.0 - max(dot(N, viewDir), 0.0), 3.0);\n outColor = outColor * 0.7 + vec3<f32>(1.0, 0.95, 0.6) * smoothstep(0.35, 0.9, fresnel);\n }\n\n // Hologram shader override (shaderMode == 2): fresnel rim + scanline pulse\n // tinted toward the prop's own base color. Opaque output (the pipeline has\n // no blend state) but reads as a hologram.\n if (shaderMode == 2u) {\n let viewDir = normalize(uniforms.cameraPos - input.worldPos);\n let fresnel = pow(1.0 - max(dot(N, viewDir), 0.0), 2.5);\n let pulse = 0.65 + 0.35 * sin(uniforms.time * 5.0 + input.worldPos.y * 3.0);\n let scan = 0.5 + 0.5 * sin((input.worldPos.y + uniforms.time * 2.0) * 20.0);\n let holoBase = baseColor * vec3<f32>(0.3, 0.9, 1.1) + vec3<f32>(0.05, 0.2, 0.25);\n let holoRim = vec3<f32>(0.7, 1.0, 1.0);\n var holoCol = mix(holoBase, holoRim, fresnel) * pulse;\n holoCol = holoCol + holoRim * scan * 0.15 * fresnel;\n return vec4<f32>(holoCol, 1.0);\n }\n\n // Ghost hologram override (highlight == 1): render the prop as a\n // translucent-looking cyan shell with a fresnel rim and a vertical\n // scanline pulse so it's visually distinct from normally-grabbed (solid)\n // props. Opaque output (the pipeline has no blend state) but reads as a\n // hologram. Hover outline (highlight == 2) is handled by a separate\n // inverted-hull draw — see vs_outline / fs_outline below.\n if (uniforms.highlight > 0.5 && uniforms.highlight < 1.5) {\n let viewDir = normalize(uniforms.cameraPos - input.worldPos);\n let fresnel = pow(1.0 - max(dot(N, viewDir), 0.0), 2.5);\n let pulse = 0.65 + 0.35 * sin(uniforms.time * 5.0 + input.worldPos.y * 3.0);\n let scan = 0.5 + 0.5 * sin((input.worldPos.y + uniforms.time * 2.0) * 20.0);\n let ghostBase = vec3<f32>(0.15, 0.75, 0.95);\n let ghostRim = vec3<f32>(0.7, 1.0, 1.0);\n var ghostCol = mix(ghostBase, ghostRim, fresnel) * pulse;\n ghostCol = ghostCol + ghostRim * scan * 0.15 * fresnel;\n return vec4<f32>(ghostCol, 1.0);\n }\n\n return vec4<f32>(outColor, 1.0);\n}\n\n// ── Mask entry point for post-process outline ──\n// Renders the model as solid white to a mask texture. Uses the same\n// vertex shader as the normal render (vs_main / vs_skinned).\n@fragment\nfn fs_mask() -> @location(0) vec4f {\n return vec4f(1.0, 1.0, 1.0, 1.0);\n}\n";
13
13
  const log = createLogger();
14
14
  export class ModelRenderer {
15
15
  device;
@@ -50,9 +50,11 @@ export class ModelRenderer {
50
50
  reusableUniforms = new Float32Array(64);
51
51
  reusableUniformsU32 = new Uint32Array(this.reusableUniforms.buffer);
52
52
  textureLoadVersion = new Map();
53
- // Skinning: a shared storage buffer of bone matrices (MAX_BONES * mat4),
54
- // updated per-frame via updateSkinMatrices. Bound at @group(1) for skinned
55
- // draws only.
53
+ // Skinning: a shared storage buffer of bone matrices (mat4 each), updated
54
+ // per-frame via updateSkinMatrices/updateSkinMatricesFor. Bound at @group(1)
55
+ // for skinned draws only. Each skinned nodeId owns a region of the buffer —
56
+ // the draw's `boneBase` uniform selects it so multiple skinned models can be
57
+ // animated independently in one frame.
56
58
  skinMatrixBuffer = null;
57
59
  skinBindGroupLayout = null;
58
60
  skinBindGroup = null;
@@ -60,6 +62,16 @@ export class ModelRenderer {
60
62
  skinBindGroupSetThisFrame = false;
61
63
  /** Current bone capacity (number of mat4s the skin buffer can hold). Grows on demand. */
62
64
  boneCapacity = ModelRenderer.INITIAL_BONE_CAPACITY;
65
+ /** nodeId → reserved bone region (offset+count in bones). */
66
+ skinRegions = new Map();
67
+ /** nodeId → bone base index used by the skinning shader for that draw. */
68
+ skinBaseByNode = new Map();
69
+ /** Freed bone regions available for reuse (first-fit). */
70
+ skinFreeList = [];
71
+ /** Next never-allocated bone index (high-water mark). */
72
+ nextBoneOffset = 0;
73
+ /** Legacy caller region — updateSkinMatrices(m) without a nodeId. */
74
+ static DEFAULT_SKIN_NODE = "__skin_default";
63
75
  // Bindless deps
64
76
  bindless = null;
65
77
  bindlessLayout = null;
@@ -757,6 +769,7 @@ export class ModelRenderer {
757
769
  }
758
770
  }
759
771
  removeModel(nodeId) {
772
+ this.releaseSkinRegion(nodeId);
760
773
  const resources = this.modelResources.get(nodeId);
761
774
  if (resources) {
762
775
  for (let i = 0; i < resources.length; i++) {
@@ -1004,36 +1017,77 @@ export class ModelRenderer {
1004
1017
  return this.mipmapsEnabled;
1005
1018
  }
1006
1019
  /**
1007
- * Upload bone skin matrices for the current frame. The `matrices` buffer is
1008
- * a flat Float32Array of boneCount * 16 floats (column-major mat4s). It is
1009
- * copied into the shared skin matrix storage buffer. If the buffer is too
1010
- * small for the current skeleton, it is recreated (grown) automatically.
1011
- * Call once per frame before render() for skinned models.
1020
+ * Upload bone skin matrices for the current frame into the shared default
1021
+ * region. Kept for single-avatar callers — equivalent to
1022
+ * updateSkinMatricesFor("__skin_default", matrices).
1012
1023
  */
1013
1024
  updateSkinMatrices(matrices) {
1025
+ this.updateSkinMatricesFor(ModelRenderer.DEFAULT_SKIN_NODE, matrices);
1026
+ }
1027
+ /**
1028
+ * Upload bone skin matrices for one nodeId's reserved region. The region is
1029
+ * (re)allocated automatically — freed regions from removeModel() are reused
1030
+ * first-fit. Render() looks up the node's region offset and passes it to the
1031
+ * skinning shader via the `boneBase` uniform, so multiple skinned models
1032
+ * animate independently.
1033
+ */
1034
+ updateSkinMatricesFor(nodeId, matrices) {
1014
1035
  if (!this.skinMatrixBuffer)
1015
1036
  return;
1016
1037
  const requiredBones = matrices.length / 16;
1017
- if (requiredBones > this.boneCapacity) {
1018
- // Grow the skin matrix buffer to fit the larger skeleton.
1019
- // Round up to the next power of 2 to reduce reallocations.
1020
- const newCapacity = Math.max(requiredBones, this.boneCapacity * 2);
1021
- this.skinMatrixBuffer.destroy();
1022
- this.skinMatrixBuffer = this.device.createBuffer({
1023
- size: newCapacity * 64,
1024
- usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
1025
- });
1026
- this.boneCapacity = newCapacity;
1027
- // Recreate the bind group with the new buffer
1028
- if (this.skinBindGroupLayout) {
1029
- this.skinBindGroup = this.device.createBindGroup({
1030
- layout: this.skinBindGroupLayout,
1031
- entries: [{ binding: 0, resource: { buffer: this.skinMatrixBuffer } }],
1032
- });
1038
+ let region = this.skinRegions.get(nodeId);
1039
+ if (!region || region.count < requiredBones) {
1040
+ if (region)
1041
+ this.skinFreeList.push(region);
1042
+ region = this.allocSkinRegion(requiredBones);
1043
+ this.skinRegions.set(nodeId, region);
1044
+ }
1045
+ this.skinBaseByNode.set(nodeId, region.offset);
1046
+ this.device.queue.writeBuffer(this.skinMatrixBuffer, region.offset * 64, matrices);
1047
+ }
1048
+ /** Allocate a bone region for `count` bones — free-list first, then bump
1049
+ * allocation; grows the shared buffer when the capacity is exceeded. */
1050
+ allocSkinRegion(count) {
1051
+ for (let i = 0; i < this.skinFreeList.length; i++) {
1052
+ const f = this.skinFreeList[i];
1053
+ if (f.count >= count) {
1054
+ this.skinFreeList.splice(i, 1);
1055
+ return { offset: f.offset, count: f.count };
1033
1056
  }
1034
- this.skinBindGroupSetThisFrame = false; // force re-bind
1035
1057
  }
1036
- this.device.queue.writeBuffer(this.skinMatrixBuffer, 0, matrices);
1058
+ const needed = this.nextBoneOffset + count;
1059
+ if (needed > this.boneCapacity)
1060
+ this.growSkinBuffer(needed);
1061
+ const region = { offset: this.nextBoneOffset, count };
1062
+ this.nextBoneOffset += count;
1063
+ return region;
1064
+ }
1065
+ /** Recreate the skin matrix buffer at a larger capacity and rebind group(1).
1066
+ * Region offsets stay valid — callers rewrite their matrices next frame. */
1067
+ growSkinBuffer(minBones) {
1068
+ const newCapacity = Math.max(minBones, this.boneCapacity * 2);
1069
+ this.skinMatrixBuffer?.destroy();
1070
+ this.skinMatrixBuffer = this.device.createBuffer({
1071
+ size: newCapacity * 64,
1072
+ usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
1073
+ });
1074
+ this.boneCapacity = newCapacity;
1075
+ if (this.skinBindGroupLayout) {
1076
+ this.skinBindGroup = this.device.createBindGroup({
1077
+ layout: this.skinBindGroupLayout,
1078
+ entries: [{ binding: 0, resource: { buffer: this.skinMatrixBuffer } }],
1079
+ });
1080
+ }
1081
+ this.skinBindGroupSetThisFrame = false; // force re-bind
1082
+ }
1083
+ /** Release a node's bone region back to the free list. */
1084
+ releaseSkinRegion(nodeId) {
1085
+ const region = this.skinRegions.get(nodeId);
1086
+ if (!region)
1087
+ return;
1088
+ this.skinRegions.delete(nodeId);
1089
+ this.skinBaseByNode.delete(nodeId);
1090
+ this.skinFreeList.push(region);
1037
1091
  }
1038
1092
  render(passEncoder, nodeId, position, rotation, scale, instanceIndex = 0,
1039
1093
  /** Per-draw highlight: 0 = normal shading, 1 = ghost hologram (cyan),
@@ -1132,6 +1186,12 @@ export class ModelRenderer {
1132
1186
  uniforms[40] = 0.0;
1133
1187
  uniforms[41] = 0.0;
1134
1188
  uniforms[42] = 0.0;
1189
+ // boneBase (u32, slot 44) — index of this node's bone region inside the
1190
+ // shared skinMatrices storage buffer. Only read by vs_skinned.
1191
+ this.reusableUniformsU32[44] =
1192
+ this.skinBaseByNode.get(nodeId)
1193
+ ?? this.skinBaseByNode.get(ModelRenderer.DEFAULT_SKIN_NODE)
1194
+ ?? 0;
1135
1195
  this.device.queue.writeBuffer(this.uniformBuffer, uniformOffset, uniforms);
1136
1196
  // Select the skinned pipeline + skin vertex buffer for skinned meshes,
1137
1197
  // otherwise the standard non-skinned pipeline.
@@ -22,6 +22,14 @@ struct Uniforms {
22
22
  // Inverted-hull outline parameters (used by vs_outline / fs_outline only).
23
23
  outlineWidth: f32,
24
24
  outlineColor: vec3<f32>,
25
+ // Base index of this draw's bone region inside the shared skinMatrices
26
+ // storage buffer (group(1)). Written by ModelRenderer from the node's
27
+ // reserved region — lets multiple skinned models animate independently.
28
+ // Only read by vs_skinned.
29
+ boneBase: u32,
30
+ _padSkin0: u32,
31
+ _padSkin1: u32,
32
+ _padSkin2: u32,
25
33
  };
26
34
 
27
35
  @group(0) @binding(0) var<uniform> uniforms: Uniforms;
@@ -165,10 +173,11 @@ fn qrotate(q: vec4<f32>, v: vec3<f32>) -> vec3<f32> {
165
173
  // If all weights are zero (vertex has no bone assignment), return identity so
166
174
  // the vertex renders at its baked position instead of collapsing to the origin.
167
175
  fn skinMatrix(j: vec4<u32>, w: vec4<f32>) -> mat4x4<f32> {
168
- let m = skinMatrices[j.x] * w.x
169
- + skinMatrices[j.y] * w.y
170
- + skinMatrices[j.z] * w.z
171
- + skinMatrices[j.w] * w.w;
176
+ let base = uniforms.boneBase;
177
+ let m = skinMatrices[base + j.x] * w.x
178
+ + skinMatrices[base + j.y] * w.y
179
+ + skinMatrices[base + j.z] * w.z
180
+ + skinMatrices[base + j.w] * w.w;
172
181
  let wsum = w.x + w.y + w.z + w.w;
173
182
  if (wsum < 0.001) {
174
183
  return mat4x4<f32>(
@@ -14,3 +14,4 @@ export { AuthorityManager } from "./authority.js";
14
14
  // Engine library descriptor (declarative GameModule wiring)
15
15
  export { NetworkClientTok, NetworkingLib } from "./library.js";
16
16
  export { LanDiscovery, MdnsBrowser } from "./discovery.js";
17
+ export { createWebSocketServer } from "./ws-server.js";