@downdraft/engine 0.2.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 (138) 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/index.ts +2 -2
  6. package/core/src/module/host.ts +3 -6
  7. package/core/src/module/index.ts +1 -1
  8. package/core/src/module/module.ts +4 -47
  9. package/core/src/physics/interface.ts +5 -0
  10. package/core/src/platform/render-surface.ts +13 -0
  11. package/core/src/platform/rpc.ts +4 -1
  12. package/core/src/render/canvas-resize-watcher.ts +1 -10
  13. package/core/src/render/game-renderer.ts +164 -79
  14. package/core/src/render/index.ts +0 -2
  15. package/core/src/render/input-manager.ts +7 -7
  16. package/core/src/render/renderer-module-host.ts +2 -4
  17. package/core/src/sab/grid-buffer.ts +55 -2
  18. package/core/src/sab/record-buffer.ts +48 -5
  19. package/core/src/sab/sim-channel.ts +181 -17
  20. package/core/src/sab/slot-buffer.ts +55 -1
  21. package/core/src/sab/types.ts +11 -0
  22. package/core/src/telemetry/gpu-resource-tracker.ts +24 -0
  23. package/core/src/ui-font-scale.ts +5 -38
  24. package/core/src/util/index.ts +1 -0
  25. package/core/src/util/session-timer.ts +23 -0
  26. package/core/src/worker/base-worker-host.ts +2 -1
  27. package/core/src/worker/buffer-sync.ts +20 -13
  28. package/core/src/worker/rpc.ts +119 -5
  29. package/core/src/worker/sim-worker-base.ts +5 -2
  30. package/core/src/worker/task-pool.ts +4 -0
  31. package/dist/.dd-manifest.json +893 -908
  32. package/dist/app/src/renderer/bootstrap.js +0 -4
  33. package/dist/app/src/renderer/feature-log.js +1 -10
  34. package/dist/app/src/renderer/mcp-harness.js +8 -11
  35. package/dist/app/src/renderer/standard-automation-tools.js +6 -138
  36. package/dist/core/src/index.js +2 -2
  37. package/dist/core/src/module/host.js +3 -6
  38. package/dist/core/src/platform/rpc.js +2 -1
  39. package/dist/core/src/render/canvas-resize-watcher.js +1 -10
  40. package/dist/core/src/render/game-renderer.js +166 -74
  41. package/dist/core/src/render/index.js +0 -1
  42. package/dist/core/src/render/input-manager.js +7 -7
  43. package/dist/core/src/render/renderer-module-host.js +2 -4
  44. package/dist/core/src/sab/grid-buffer.js +53 -1
  45. package/dist/core/src/sab/record-buffer.js +49 -5
  46. package/dist/core/src/sab/sim-channel.js +173 -17
  47. package/dist/core/src/sab/slot-buffer.js +54 -1
  48. package/dist/core/src/telemetry/gpu-resource-tracker.js +26 -0
  49. package/dist/core/src/ui-font-scale.js +5 -38
  50. package/dist/core/src/util/index.js +1 -0
  51. package/dist/core/src/util/session-timer.js +22 -0
  52. package/dist/core/src/worker/base-worker-host.js +2 -1
  53. package/dist/core/src/worker/buffer-sync.js +16 -9
  54. package/dist/core/src/worker/rpc.js +105 -8
  55. package/dist/core/src/worker/sim-worker-base.js +5 -2
  56. package/dist/core/src/worker/task-pool.js +5 -0
  57. package/dist/libraries/blitz-ui/src/native-module.js +8 -1
  58. package/dist/libraries/devtools/src/backend.js +35 -9
  59. package/dist/libraries/devtools/src/collectors.js +73 -22
  60. package/dist/libraries/devtools/src/native-providers.js +0 -2
  61. package/dist/libraries/entities/src/model-renderer.js +87 -27
  62. package/dist/libraries/entities/src/shaders/model.wgsl +13 -4
  63. package/dist/libraries/networking/src/index.js +1 -0
  64. package/dist/libraries/networking/src/ws-server.js +387 -0
  65. package/dist/libraries/physics-rapier/src/api.js +15 -0
  66. package/dist/libraries/physics-rapier/src/backend.js +15 -0
  67. package/dist/libraries/physics-rapier/src/rapier-backend.js +5 -0
  68. package/dist/libraries/postfx/src/post-process-stack.js +1 -3
  69. package/dist/libraries/sand/src/materials.js +2 -32
  70. package/dist/libraries/sand/src/sand-world.js +0 -3
  71. package/dist/modules/devtools/src/api.js +8 -23
  72. package/dist/modules/devtools/src/blitz/fonts.js +75 -0
  73. package/dist/modules/devtools/src/blitz/host.js +124 -30
  74. package/dist/modules/devtools/src/blitz/panels/console.js +19 -7
  75. package/dist/modules/devtools/src/blitz/panels/elements.js +217 -28
  76. package/dist/modules/devtools/src/blitz/panels/gpu.js +5 -2
  77. package/dist/modules/devtools/src/blitz/panels/performance.js +26 -12
  78. package/dist/modules/devtools/src/blitz/panels/snapshot.js +31 -8
  79. package/dist/modules/devtools/src/blitz/panels/types.js +2 -1
  80. package/dist/modules/devtools/src/blitz/theme.js +26 -4
  81. package/dist/modules/devtools/src/data-bridge.js +0 -11
  82. package/dist/modules/devtools/src/index.js +0 -6
  83. package/dist/modules/devtools/src/init.js +8 -42
  84. package/dist/modules/devtools/src/scene-inspector.js +1 -4
  85. package/dist/modules/devtools/src/worker-sync.js +3 -11
  86. package/dist/modules/html-ui/src/doc-backend.js +52 -3
  87. package/dist/modules/html-ui/src/host.js +6 -3
  88. package/dist/test/src/vision-test.js +1 -3
  89. package/libraries/blitz-ui/native/Cargo.toml +6 -0
  90. package/libraries/blitz-ui/native-osr/Cargo.toml +3 -1
  91. package/libraries/blitz-ui/src/native-module.ts +8 -1
  92. package/libraries/devtools/src/backend.ts +38 -12
  93. package/libraries/devtools/src/collectors.ts +75 -22
  94. package/libraries/devtools/src/native-providers.ts +0 -2
  95. package/libraries/entities/src/model-renderer.ts +88 -27
  96. package/libraries/entities/src/shaders/model.wgsl +13 -4
  97. package/libraries/networking/src/index.ts +4 -0
  98. package/libraries/networking/src/ws-server.ts +397 -0
  99. package/libraries/physics-rapier/src/api.ts +15 -0
  100. package/libraries/physics-rapier/src/backend.ts +15 -0
  101. package/libraries/physics-rapier/src/rapier-backend.ts +6 -0
  102. package/libraries/postfx/src/post-process-stack.ts +1 -2
  103. package/libraries/sand/src/materials.ts +2 -32
  104. package/libraries/sand/src/sand-world.ts +0 -4
  105. package/modules/devtools/README.md +1 -3
  106. package/modules/devtools/src/api.ts +10 -34
  107. package/modules/devtools/src/blitz/fonts.ts +78 -0
  108. package/modules/devtools/src/blitz/host.ts +107 -31
  109. package/modules/devtools/src/blitz/panels/console.ts +20 -8
  110. package/modules/devtools/src/blitz/panels/elements.ts +212 -31
  111. package/modules/devtools/src/blitz/panels/gpu.ts +4 -2
  112. package/modules/devtools/src/blitz/panels/performance.ts +24 -12
  113. package/modules/devtools/src/blitz/panels/snapshot.ts +30 -8
  114. package/modules/devtools/src/blitz/panels/types.ts +2 -1
  115. package/modules/devtools/src/blitz/theme.ts +26 -4
  116. package/modules/devtools/src/data-bridge.ts +0 -16
  117. package/modules/devtools/src/index.ts +1 -11
  118. package/modules/devtools/src/init.ts +8 -51
  119. package/modules/devtools/src/scene-inspector.ts +2 -6
  120. package/modules/devtools/src/types.ts +5 -90
  121. package/modules/devtools/src/worker-sync.ts +3 -13
  122. package/modules/html-ui/src/doc-backend.ts +67 -4
  123. package/modules/html-ui/src/host.ts +11 -3
  124. package/modules/html-ui/src/protocol.ts +1 -1
  125. package/package.json +4 -5
  126. package/test/src/vision-test.ts +2 -4
  127. package/core/src/render/sab-canvas-overlay.ts +0 -122
  128. package/dist/core/src/render/sab-canvas-overlay.js +0 -83
  129. package/dist/modules/devtools/src/debug-overlay.js +0 -181
  130. package/dist/modules/devtools/src/doctor-panel.js +0 -163
  131. package/dist/modules/devtools/src/label-overlay.js +0 -129
  132. package/dist/modules/devtools/src/material-stats-panel.js +0 -171
  133. package/dist/modules/devtools/src/sim-stats-panel.js +0 -250
  134. package/modules/devtools/src/debug-overlay.ts +0 -238
  135. package/modules/devtools/src/doctor-panel.ts +0 -182
  136. package/modules/devtools/src/label-overlay.ts +0 -154
  137. package/modules/devtools/src/material-stats-panel.ts +0 -188
  138. package/modules/devtools/src/sim-stats-panel.ts +0 -272
@@ -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";
@@ -0,0 +1,387 @@
1
+ // ============================================================================
2
+ // ws-server.ts — minimal tri-runtime WebSocket server for host-authoritative
3
+ // LAN/WAN sessions. The networking library ships client transports
4
+ // (WebSocketTransport, WebRTCTransport) but had no listener — this fills the
5
+ // host side so a game instance can accept direct connections without any
6
+ // external service.
7
+ //
8
+ // Runtime selection is capability-based (see platform/runtime.ts):
9
+ // Bun → Bun.serve({ fetch, websocket })
10
+ // Deno → Deno.serve + Deno.upgradeWebSocket
11
+ // Node → node:http + a hand-rolled RFC6455 handshake/framer (pure JS,
12
+ // no native deps — also the fallback for any runtime with node:http)
13
+ //
14
+ // Semantics intentionally mirror the browser WebSocket surface: text frames
15
+ // arrive as `string`, binary frames as `Uint8Array`. Ping/pong/close and
16
+ // fragmented-frame reassembly are handled internally. Message cap matches
17
+ // WebSocketTransport's 16 MiB.
18
+ // ============================================================================
19
+ import { createLogger } from "@downdraft/engine/util/logger";
20
+ const log = createLogger();
21
+ const WS_GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11";
22
+ const MAX_MESSAGE_SIZE = 16 * 1024 * 1024;
23
+ function makeServerBase(handlers) {
24
+ const conns = new Set();
25
+ let nextId = 1;
26
+ const deliver = (conn, data) => {
27
+ const size = typeof data === "string" ? data.length : data.byteLength;
28
+ if (size > MAX_MESSAGE_SIZE) {
29
+ conn.close(1009, "message too large");
30
+ return;
31
+ }
32
+ try {
33
+ handlers.onMessage?.(conn, data);
34
+ }
35
+ catch (e) {
36
+ log.error("WsServer", `onMessage threw: ${e}`);
37
+ }
38
+ };
39
+ const drop = (conn, code, reason) => {
40
+ if (!conns.delete(conn))
41
+ return;
42
+ try {
43
+ handlers.onClose?.(conn, code, reason);
44
+ }
45
+ catch (e) {
46
+ log.error("WsServer", `onClose threw: ${e}`);
47
+ }
48
+ };
49
+ return { conns, nextId: () => nextId++, deliver, drop };
50
+ }
51
+ // ---------------------------------------------------------------------------
52
+ // Bun — Bun.serve with the `websocket` handler map
53
+ // ---------------------------------------------------------------------------
54
+ function startBunServer(opts) {
55
+ const Bun = globalThis.Bun;
56
+ const base = makeServerBase(opts);
57
+ const server = Bun.serve({
58
+ port: opts.port,
59
+ hostname: opts.host ?? "0.0.0.0",
60
+ fetch(req, srv) {
61
+ const ip = srv.requestIP(req);
62
+ const conn = {
63
+ id: base.nextId(),
64
+ remoteAddress: `${ip?.address ?? "?"}:${ip?.port ?? "?"}`,
65
+ raw: null,
66
+ send(data) { if (this.raw)
67
+ this.raw.send(data instanceof ArrayBuffer ? new Uint8Array(data) : data); },
68
+ close(code = 1000, reason = "") { if (this.raw)
69
+ this.raw.close(code, reason); },
70
+ };
71
+ return srv.upgrade(req, { data: { conn } }) ? undefined : new Response("WebSocket upgrade required", { status: 426 });
72
+ },
73
+ websocket: {
74
+ open(ws) {
75
+ const conn = ws.data.conn;
76
+ conn.raw = ws;
77
+ base.conns.add(conn);
78
+ try {
79
+ opts.onConnection?.(conn);
80
+ }
81
+ catch (e) {
82
+ log.error("WsServer", `onConnection threw: ${e}`);
83
+ }
84
+ },
85
+ message(ws, msg) {
86
+ const conn = ws.data.conn;
87
+ base.deliver(conn, typeof msg === "string" ? msg : new Uint8Array(msg));
88
+ },
89
+ close(ws, code, reason) {
90
+ base.drop(ws.data.conn, code, reason);
91
+ },
92
+ },
93
+ });
94
+ return Promise.resolve({
95
+ port: server.port,
96
+ host: opts.host ?? "0.0.0.0",
97
+ connections: () => [...base.conns],
98
+ async close() {
99
+ [...base.conns].forEach((c) => c.close(1001, "server shutdown"));
100
+ await server.stop(true);
101
+ },
102
+ });
103
+ }
104
+ // ---------------------------------------------------------------------------
105
+ // Deno — Deno.serve + Deno.upgradeWebSocket
106
+ // ---------------------------------------------------------------------------
107
+ function startDenoServer(opts) {
108
+ const Deno = globalThis.Deno;
109
+ const base = makeServerBase(opts);
110
+ const server = Deno.serve({ port: opts.port, hostname: opts.host ?? "0.0.0.0" }, (req, info) => {
111
+ if (req.headers.get("upgrade")?.toLowerCase() !== "websocket") {
112
+ return new Response("WebSocket upgrade required", { status: 426 });
113
+ }
114
+ const { socket, response } = Deno.upgradeWebSocket(req);
115
+ const conn = {
116
+ id: base.nextId(),
117
+ remoteAddress: info?.remoteAddr ? `${info.remoteAddr.hostname}:${info.remoteAddr.port}` : "?",
118
+ raw: socket,
119
+ send(data) { try {
120
+ socket.send(data);
121
+ }
122
+ catch { /* closing */ } },
123
+ close(code = 1000, reason = "") { try {
124
+ socket.close(code, reason);
125
+ }
126
+ catch { /* already closed */ } },
127
+ };
128
+ socket.binaryType = "arraybuffer";
129
+ socket.onopen = () => {
130
+ base.conns.add(conn);
131
+ try {
132
+ opts.onConnection?.(conn);
133
+ }
134
+ catch (e) {
135
+ log.error("WsServer", `onConnection threw: ${e}`);
136
+ }
137
+ };
138
+ socket.onmessage = (e) => {
139
+ base.deliver(conn, typeof e.data === "string" ? e.data : new Uint8Array(e.data));
140
+ };
141
+ socket.onclose = (e) => base.drop(conn, e.code, e.reason);
142
+ socket.onerror = () => base.drop(conn, 1006, "socket error");
143
+ return response;
144
+ });
145
+ const bound = server.addr;
146
+ return Promise.resolve({
147
+ port: bound.port,
148
+ host: bound.hostname,
149
+ connections: () => [...base.conns],
150
+ async close() {
151
+ [...base.conns].forEach((c) => c.close(1001, "server shutdown"));
152
+ await server.shutdown();
153
+ },
154
+ });
155
+ }
156
+ // ---------------------------------------------------------------------------
157
+ // Node (and generic fallback) — node:http + hand-rolled RFC6455
158
+ // ---------------------------------------------------------------------------
159
+ /** Encode one server→client frame. Server frames are never masked. */
160
+ function encodeWsFrame(opcode, payload) {
161
+ const len = payload.byteLength;
162
+ let header;
163
+ if (len < 126) {
164
+ header = new Uint8Array(2);
165
+ header[1] = len;
166
+ }
167
+ else if (len <= 0xffff) {
168
+ header = new Uint8Array(4);
169
+ header[1] = 126;
170
+ header[2] = len >>> 8;
171
+ header[3] = len & 0xff;
172
+ }
173
+ else {
174
+ header = new Uint8Array(10);
175
+ header[1] = 127;
176
+ const dv = new DataView(header.buffer);
177
+ dv.setUint32(2, Math.floor(len / 0x100000000));
178
+ dv.setUint32(6, len >>> 0);
179
+ }
180
+ header[0] = 0x80 | (opcode & 0x0f);
181
+ const out = new Uint8Array(header.length + len);
182
+ out.set(header, 0);
183
+ out.set(payload, header.length);
184
+ return out;
185
+ }
186
+ const OP_CONT = 0x0, OP_TEXT = 0x1, OP_BIN = 0x2, OP_CLOSE = 0x8, OP_PING = 0x9, OP_PONG = 0xa;
187
+ async function startNodeServer(opts) {
188
+ const http = await import("node:http");
189
+ const crypto = await import("node:crypto");
190
+ const base = makeServerBase(opts);
191
+ const httpServer = http.createServer((_req, res) => {
192
+ res.writeHead(426, { "Content-Type": "text/plain" });
193
+ res.end("WebSocket upgrade required");
194
+ });
195
+ httpServer.on("upgrade", (req, socket) => {
196
+ socket.setNoDelay(true);
197
+ const key = req.headers["sec-websocket-key"];
198
+ const upgrade = req.headers.upgrade?.toLowerCase();
199
+ if (upgrade !== "websocket" || typeof key !== "string") {
200
+ socket.write("HTTP/1.1 400 Bad Request\r\n\r\n");
201
+ socket.destroy();
202
+ return;
203
+ }
204
+ const accept = crypto.createHash("sha1").update(key + WS_GUID).digest("base64");
205
+ socket.write("HTTP/1.1 101 Switching Protocols\r\n" +
206
+ "Upgrade: websocket\r\n" +
207
+ "Connection: Upgrade\r\n" +
208
+ `Sec-WebSocket-Accept: ${accept}\r\n\r\n`);
209
+ let closed = false;
210
+ const conn = {
211
+ id: base.nextId(),
212
+ remoteAddress: `${socket.remoteAddress ?? "?"}:${socket.remotePort ?? "?"}`,
213
+ raw: socket,
214
+ send(data) {
215
+ if (closed || socket.destroyed)
216
+ return;
217
+ if (typeof data === "string") {
218
+ socket.write(Buffer.from(encodeWsFrame(OP_TEXT, Buffer.from(data, "utf8"))));
219
+ }
220
+ else {
221
+ const bytes = data instanceof ArrayBuffer ? new Uint8Array(data) : data;
222
+ socket.write(Buffer.from(encodeWsFrame(OP_BIN, bytes)));
223
+ }
224
+ },
225
+ close(code = 1000, reason = "") {
226
+ if (closed)
227
+ return;
228
+ closed = true;
229
+ const payload = new Uint8Array(2 + reason.length);
230
+ payload[0] = code >>> 8;
231
+ payload[1] = code & 0xff;
232
+ payload.set(new TextEncoder().encode(reason), 2);
233
+ try {
234
+ socket.write(Buffer.from(encodeWsFrame(OP_CLOSE, payload)));
235
+ socket.end();
236
+ }
237
+ catch { /* socket already gone */ }
238
+ },
239
+ };
240
+ // ── Incremental frame parser ──
241
+ let buf = Buffer.alloc(0);
242
+ let fragOp = -1;
243
+ const frags = [];
244
+ const finishMessage = (op, payload) => {
245
+ if (payload.byteLength > MAX_MESSAGE_SIZE) {
246
+ conn.close(1009, "message too large");
247
+ return;
248
+ }
249
+ if (op === OP_TEXT)
250
+ base.deliver(conn, payload.toString("utf8"));
251
+ else
252
+ base.deliver(conn, new Uint8Array(payload.buffer, payload.byteOffset, payload.byteLength));
253
+ };
254
+ const handleFrame = (fin, op, payload) => {
255
+ if (op === OP_CLOSE) {
256
+ const code = payload.byteLength >= 2 ? payload.readUInt16BE(0) : 1000;
257
+ const reason = payload.byteLength > 2 ? payload.subarray(2).toString("utf8") : "";
258
+ conn.close(code, reason);
259
+ base.drop(conn, code, reason);
260
+ return;
261
+ }
262
+ if (op === OP_PING) {
263
+ if (!closed && !socket.destroyed)
264
+ socket.write(Buffer.from(encodeWsFrame(OP_PONG, payload)));
265
+ return;
266
+ }
267
+ if (op === OP_PONG)
268
+ return;
269
+ if (op === OP_CONT && fragOp < 0) {
270
+ conn.close(1002, "unexpected continuation");
271
+ return;
272
+ }
273
+ if ((op === OP_TEXT || op === OP_BIN) && fragOp >= 0) {
274
+ conn.close(1002, "interleaved frame");
275
+ return;
276
+ }
277
+ if (op !== OP_CONT)
278
+ fragOp = op;
279
+ frags.push(payload);
280
+ if (!fin)
281
+ return;
282
+ const full = Buffer.concat(frags);
283
+ frags.length = 0;
284
+ const finalOp = fragOp;
285
+ fragOp = -1;
286
+ finishMessage(finalOp, full);
287
+ };
288
+ socket.on("data", (chunk) => {
289
+ buf = buf.byteLength ? Buffer.concat([buf, chunk]) : chunk;
290
+ // Parse as many complete frames as the buffer holds.
291
+ for (;;) {
292
+ if (buf.byteLength < 2)
293
+ return;
294
+ const b0 = buf[0], b1 = buf[1];
295
+ const fin = (b0 & 0x80) !== 0;
296
+ const op = b0 & 0x0f;
297
+ const masked = (b1 & 0x80) !== 0;
298
+ let len = b1 & 0x7f;
299
+ let off = 2;
300
+ if (len === 126) {
301
+ if (buf.byteLength < off + 2)
302
+ return;
303
+ len = buf.readUInt16BE(off);
304
+ off += 2;
305
+ }
306
+ else if (len === 127) {
307
+ if (buf.byteLength < off + 8)
308
+ return;
309
+ const hi = buf.readUInt32BE(off), lo = buf.readUInt32BE(off + 4);
310
+ off += 8;
311
+ if (hi > 0 || lo > MAX_MESSAGE_SIZE) {
312
+ conn.close(1009, "message too large");
313
+ return;
314
+ }
315
+ len = lo;
316
+ }
317
+ const maskLen = masked ? 4 : 0;
318
+ if (buf.byteLength < off + maskLen + len)
319
+ return;
320
+ let payload = buf.subarray(off + maskLen, off + maskLen + len);
321
+ if (masked) {
322
+ const mask = buf.subarray(off, off + 4);
323
+ const unmasked = Buffer.allocUnsafe(payload.byteLength);
324
+ for (let i = 0; i < payload.byteLength; i++)
325
+ unmasked[i] = payload[i] ^ mask[i & 3];
326
+ payload = unmasked;
327
+ }
328
+ else {
329
+ payload = Buffer.from(payload); // detach — subarray aliases buf
330
+ }
331
+ buf = buf.subarray(off + maskLen + len);
332
+ handleFrame(fin, op, payload);
333
+ if (closed)
334
+ return;
335
+ }
336
+ });
337
+ const onSocketEnd = () => base.drop(conn, 1006, "socket closed");
338
+ socket.on("close", onSocketEnd);
339
+ socket.on("error", onSocketEnd);
340
+ base.conns.add(conn);
341
+ try {
342
+ opts.onConnection?.(conn);
343
+ }
344
+ catch (e) {
345
+ log.error("WsServer", `onConnection threw: ${e}`);
346
+ }
347
+ });
348
+ await new Promise((resolve, reject) => {
349
+ httpServer.once("error", reject);
350
+ httpServer.listen(opts.port, opts.host ?? "0.0.0.0", () => {
351
+ httpServer.removeListener("error", reject);
352
+ resolve();
353
+ });
354
+ });
355
+ const addr = httpServer.address();
356
+ const boundPort = typeof addr === "object" && addr ? addr.port : opts.port;
357
+ return {
358
+ port: boundPort,
359
+ host: opts.host ?? "0.0.0.0",
360
+ connections: () => [...base.conns],
361
+ async close() {
362
+ [...base.conns].forEach((c) => c.close(1001, "server shutdown"));
363
+ await new Promise((resolve) => httpServer.close(() => resolve()));
364
+ // httpServer.close waits for open sockets; force-destroy stragglers.
365
+ [...base.conns].forEach((c) => c.raw.destroy());
366
+ base.conns.clear();
367
+ },
368
+ };
369
+ }
370
+ // ---------------------------------------------------------------------------
371
+ // Entry point
372
+ // ---------------------------------------------------------------------------
373
+ /**
374
+ * Start a WebSocket server. Resolves once the socket is bound and listening.
375
+ * Runtime is picked by capability: Bun.serve → Deno.serve → node:http.
376
+ */
377
+ export function createWebSocketServer(opts) {
378
+ const g = globalThis;
379
+ const bun = g.Bun;
380
+ if (typeof bun?.serve === "function")
381
+ return startBunServer(opts);
382
+ const deno = g.Deno;
383
+ if (typeof deno?.serve === "function" && typeof deno.upgradeWebSocket === "function") {
384
+ return startDenoServer(opts);
385
+ }
386
+ return startNodeServer(opts);
387
+ }