diff --git a/editor/Assets/Types/DestructionMaterialAsset.cs b/editor/Assets/Types/DestructionMaterialAsset.cs index 8831b3df7..f5ad2c4a7 100644 --- a/editor/Assets/Types/DestructionMaterialAsset.cs +++ b/editor/Assets/Types/DestructionMaterialAsset.cs @@ -29,10 +29,28 @@ public override Task CreateAsync(string path) { $"density = 2000.0\n" + $"toughness = 100.0\n" + $"hardness = \"medium\"\n" + + $"fracture_pattern = \"brittle\"\n" + $"structural_strength = 1000000.0\n" + $"destructible = true\n" + $"flammable = false\n" + - $"burn_rate = 0.0\n"); + $"burn_rate = 0.0\n" + + $"\n[energy]\n" + + $"debris_share = 0.2\n" + + $"fragment_share = 0.5\n" + + $"\n[fragment]\n" + + $"threshold = 0.3\n" + + $"max_parts = 4\n" + + $"\n[shatter]\n" + + $"threshold = 0.1\n" + + $"cell_size = 0.5\n" + + $"crack_cost = 1.0\n" + + $"release = 0.3\n" + + $"stretch = 3.0\n" + + $"crumble = 0.5\n" + + $"\n[edge]\n" + + $"noise = 0.25\n" + + $"lobes = 0.35\n" + + $"noise_scale = 4.0\n"); return Task.CompletedTask; } } diff --git a/engine/assets/schemas/destruction_material.schema.json b/engine/assets/schemas/destruction_material.schema.json index 93854344c..29b0ee1d6 100644 --- a/engine/assets/schemas/destruction_material.schema.json +++ b/engine/assets/schemas/destruction_material.schema.json @@ -1,53 +1,199 @@ { "$schema": "http://json-schema.org/draft-07/schema#", - "$id" : "core://schemas/destruction_material.schema.json", + "$id": "core://schemas/destruction_material.schema.json", "title": "Destruction Material (.tdm)", "type": "object", - "required" : ["density", "toughness"], + "required": [ + "density", + "toughness" + ], "properties": { "density": { "type": "number", - "x-toast-type" : "float", - "x-toast-unit" : "kg/m^3", - "default" : 2000.0, - "minimum" : 1, - "maximum" : 65535 + "x-toast-type": "float", + "x-toast-unit": "kg/m^3", + "default": 2000.0, + "minimum": 1, + "maximum": 65535 }, "toughness": { "type": "number", - "x-toast-type" : "float", - "x-toast-unit" : "kJ/m^3", - "default" : 100.0, - "minimum" : 0 + "x-toast-type": "float", + "x-toast-unit": "kJ/m^3", + "default": 100.0, + "minimum": 0 }, "hardness": { - "enum" : ["soft", "medium", "hard"], - "x-toast-type" : "enum", - "default" : "medium" + "enum": [ + "soft", + "medium", + "hard" + ], + "x-toast-type": "enum", + "default": "medium" + }, + "fracture_pattern": { + "enum": [ + "brittle", + "granular", + "fibrous", + "ductile" + ], + "x-toast-type": "enum", + "default": "brittle" }, "structural_strength": { "type": "number", - "x-toast-type" : "float", - "x-toast-unit" : "Pa", - "default" : 1000000.0, - "minimum" : 0 + "x-toast-type": "float", + "x-toast-unit": "Pa", + "default": 1000000.0, + "minimum": 0 }, "destructible": { "type": "bool", - "x-toast-type" : "bool", - "default" : true + "x-toast-type": "bool", + "default": true }, "flammable": { "type": "bool", - "x-toast-type" : "bool", - "default" : false + "x-toast-type": "bool", + "default": false }, "burn_rate": { "type": "number", - "x-toast-type" : "float", - "x-toast-unit" : "s/m", - "default" : 0.0, - "minimum" : 0 + "x-toast-type": "float", + "x-toast-unit": "s/m", + "default": 0.0, + "minimum": 0 + }, + "energy": { + "x-toast-type": "energy" + }, + "fragment": { + "x-toast-type": "fragment" + }, + "shatter": { + "x-toast-type": "shatter" + }, + "edge": { + "x-toast-type": "edge" + } + }, + "definitions": { + "energy": { + "type": "object", + "properties": { + "debris_share": { + "type": "number", + "x-toast-type": "float", + "default": 0.2, + "minimum": 0, + "maximum": 1 + }, + "fragment_share": { + "type": "number", + "x-toast-type": "float", + "default": 0.5, + "minimum": 0, + "maximum": 1 + } + } + }, + "fragment": { + "type": "object", + "properties": { + "threshold": { + "type": "number", + "x-toast-type": "float", + "x-toast-unit": "x toughness", + "default": 0.3, + "minimum": 0.001, + "maximum": 65 + }, + "max_parts": { + "type": "integer", + "x-toast-type": "int", + "default": 4, + "minimum": 2, + "maximum": 32 + } + } + }, + "shatter": { + "type": "object", + "properties": { + "threshold": { + "type": "number", + "x-toast-type": "float", + "x-toast-unit": "x toughness", + "default": 0.1, + "minimum": 0.001, + "maximum": 65 + }, + "cell_size": { + "type": "number", + "x-toast-type": "float", + "x-toast-unit": "m", + "default": 0.5, + "minimum": 0.05, + "maximum": 2.5 + }, + "crack_cost": { + "type": "number", + "x-toast-type": "float", + "default": 1.0, + "minimum": 0, + "maximum": 3.9 + }, + "release": { + "type": "number", + "x-toast-type": "float", + "default": 0.3, + "minimum": 0, + "maximum": 1 + }, + "stretch": { + "type": "number", + "x-toast-type": "float", + "default": 3.0, + "minimum": 1, + "maximum": 15.9 + }, + "crumble": { + "type": "number", + "x-toast-type": "float", + "default": 0.5, + "minimum": 0, + "maximum": 1 + } + } + }, + "edge": { + "type": "object", + "properties": { + "noise": { + "type": "number", + "x-toast-type": "float", + "default": 0.25, + "minimum": 0, + "maximum": 0.9 + }, + "lobes": { + "type": "number", + "x-toast-type": "float", + "default": 0.35, + "minimum": 0, + "maximum": 0.9 + }, + "noise_scale": { + "type": "number", + "x-toast-type": "float", + "x-toast-unit": "voxels", + "default": 4.0, + "minimum": 0, + "maximum": 63 + } + } } } } diff --git a/engine/assets/shaders/debris.slang b/engine/assets/shaders/debris.slang new file mode 100644 index 000000000..bee6b3f70 --- /dev/null +++ b/engine/assets/shaders/debris.slang @@ -0,0 +1,370 @@ +// Small voxel debris called by physics::DebrisSimulation + +struct Params { + float3 gravity; + float dt; + float lifetime; + float restitution; + float friction; + float voxelSize; + uint capacity; + uint spawnCount; + uint volumeCount; + uint substeps; + uint clear; + uint pad0; + uint pad1; + uint pad2; +}; + +[[vk::push_constant]] +Params gParams; + +// most of this structs mirrors my stuff on the cpp + +struct DebrisParticle { + float3 position; + uint packed; + float4 rotation; + float3 velocity; + uint albedo; +}; + +struct DebrisMotion { + float3 angular; + float age; + uint restSteps; + uint pad0; + uint pad1; + uint pad2; +}; + +struct DebrisSpawn { + float3 position; + uint slot; + float4 rotation; + float3 velocity; + uint albedo; + float3 angular; + uint packed; +}; + +struct DebrisVolume { + float4x4 worldToVoxel; + float4 bounds; + uint recordIndex; + uint pad0; + uint pad1; + uint pad2; +}; + +[[vk::binding(0, 0)]] +StructuredBuffer gPrevious; +[[vk::binding(1, 0)]] +RWStructuredBuffer gCurrent; +[[vk::binding(2, 0)]] +RWStructuredBuffer gMotion; +[[vk::binding(3, 0)]] +RWStructuredBuffer gDrawList; +[[vk::binding(4, 0)]] +RWStructuredBuffer gDrawArgs; +[[vk::binding(5, 0)]] +StructuredBuffer gSpawns; +[[vk::binding(6, 0)]] +StructuredBuffer gVolumes; + +struct VolumeRecord { + uint brickDimsX; + uint brickDimsY; + uint brickDimsZ; + uint gridOffset; + uint coarseDimsX; + uint coarseDimsY; + uint coarseDimsZ; + uint coarseOffset; + uint paletteOffset; + float maxEmissive; +}; + +[[vk::binding(0, 1)]] +StructuredBuffer gBrickMaterials; +[[vk::binding(2, 1)]] +StructuredBuffer gVolumeGrids; +[[vk::binding(5, 1)]] +StructuredBuffer gVolumeRecords; + +static const uint kGroupSize = 64; +static const uint kBrickDim = 8; +static const uint kTagEmpty = 0; +static const uint kTagUniform = 1; + +static const uint kMaskBits = 0xFFu; +static const uint kFlagAsleep = 1u << 24; + +static const float kSleepSpeed = 0.2; // below this speed a pice is sleeping +static const uint kSleepSteps = 15; +static const float kBounceSpeed = 0.5; // slower impact sticks +static const float kContactSlop = 0.001; // next step does not start touching + +uint debrisMaterialAt(uint volume, int3 voxel) { + VolumeRecord record = gVolumeRecords[volume]; + if (any(voxel < 0) || voxel.x >= int(record.brickDimsX * kBrickDim) || voxel.y >= int(record.brickDimsY * kBrickDim) || + voxel.z >= int(record.brickDimsZ * kBrickDim)) { + return 0u; + } + + uint3 brick = uint3(voxel) >> 3; + uint entry = gVolumeGrids[record.gridOffset + brick.x + brick.y * record.brickDimsX + brick.z * (record.brickDimsX * record.brickDimsY)]; + uint tag = entry & 3u; + if (tag == kTagEmpty) { + return 0u; + } + + if (tag == kTagUniform) { + return entry >> 2; + } + + uint3 local = uint3(voxel) & 7u; + uint index = (local.z << 6) | (local.y << 3) | local.x; + uint word = gBrickMaterials[((entry >> 2) << 7) + (index >> 2)]; + return (word >> ((index & 3u) << 3)) & 0xFFu; +} + +float debrisSolid(uint volume, int3 voxel) { + return debrisMaterialAt(volume, voxel) != 0u ? 1.0 : 0.0; +} + +float3 rotateByQuat(float4 q, float3 v) { + float3 t = 2.0 * cross(q.xyz, v); + return v + q.w * t + cross(q.xyz, t); +} + +float4 quatMul(float4 a, float4 b) { + return float4(a.w * b.xyz + b.w * a.xyz + cross(a.xyz, b.xyz), a.w * b.w - dot(a.xyz, b.xyz)); +} + +float3 bitOffset(uint bit) { + return (float3(float(bit & 1u), float((bit >> 1) & 1u), float(bit >> 2)) - 0.5) * gParams.voxelSize; +} + +struct Contact { + bool hit; + float3 normal; + float depth; +}; + +Contact contactAt(float3 world) { + // deepest overlap of a point + Contact contact; + contact.hit = false; + contact.normal = float3(0.0, 0.0, 1.0); + contact.depth = 0.0; + + for (uint v = 0; v < gParams.volumeCount; ++v) { + DebrisVolume volume = gVolumes[v]; + float3 toCentre = world - volume.bounds.xyz; + if (dot(toCentre, toCentre) > volume.bounds.w * volume.bounds.w) { + continue; + } + + float3 local = mul(volume.worldToVoxel, float4(world, 1.0)).xyz; + int3 voxel = int3(floor(local)); + if (debrisMaterialAt(volume.recordIndex, voxel) == 0u) { + continue; + } + + // Points from solid to empty neighbours + // holy first time using a gradient outside math class + float3 gradient = float3( + debrisSolid(volume.recordIndex, voxel - int3(1, 0, 0)) - debrisSolid(volume.recordIndex, voxel + int3(1, 0, 0)), + debrisSolid(volume.recordIndex, voxel - int3(0, 1, 0)) - debrisSolid(volume.recordIndex, voxel + int3(0, 1, 0)), + debrisSolid(volume.recordIndex, voxel - int3(0, 0, 1)) - debrisSolid(volume.recordIndex, voxel + int3(0, 0, 1))); + + if (all(gradient == 0.0)) { + // Buried so leave towards world up + gradient = mul(volume.worldToVoxel, float4(0.0, 0.0, 1.0, 0.0)).xyz; + } + + float3 magnitude = abs(gradient); + uint axis = magnitude.x >= magnitude.y ? (magnitude.x >= magnitude.z ? 0u : 2u) : (magnitude.y >= magnitude.z ? 1u : 2u); + float side = gradient[axis] >= 0.0 ? 1.0 : -1.0; + float inside = frac(local[axis]); + float depthVoxels = side > 0.0 ? 1.0 - inside : inside; + + float3 voxelNormal = float3(0.0, 0.0, 0.0); + voxelNormal[axis] = side; + // same normal transform as voxel.slang shadeHit + float3 normal = normalize(mul(transpose((float3x3)volume.worldToVoxel), voxelNormal)); + float depth = depthVoxels * gParams.voxelSize; + + if (!contact.hit || depth > contact.depth) { + contact.hit = true; + contact.normal = normal; + contact.depth = depth; + } + } + return contact; +} + +void appendVoxels(uint index, uint mask) { + uint count = countbits(mask); + if (count == 0u) { + return; + } + uint base = 0u; + InterlockedAdd(gDrawArgs[1], count, base); + uint listCapacity = gParams.capacity * 8u; + uint written = 0u; + for (uint bit = 0u; bit < 8u; ++bit) { + if (((mask >> bit) & 1u) != 0u && base + written < listCapacity) { + gDrawList[base + written] = (index << 3) | bit; + ++written; + } + } +} + +[shader("compute")] +[numthreads(1, 1, 1)] +void resetMain(uint3 id : SV_DispatchThreadID) { + gDrawArgs[0] = 36u; + gDrawArgs[1] = 0u; + gDrawArgs[2] = 0u; + gDrawArgs[3] = 0u; +} + +[shader("compute")] +[numthreads(kGroupSize, 1, 1)] +void integrateMain(uint3 id : SV_DispatchThreadID) { + uint i = id.x; + if (i >= gParams.capacity) { + return; + } + + // write every particle + DebrisParticle particle = gPrevious[i]; + uint mask = particle.packed & kMaskBits; + if (mask == 0u || gParams.clear != 0u) { + particle.packed = 0u; + gCurrent[i] = particle; + return; + } + + DebrisMotion motion = gMotion[i]; + const float stepTime = gParams.dt * float(gParams.substeps); + motion.age += stepTime; + if (motion.age > gParams.lifetime) { + particle.packed = 0u; + gCurrent[i] = particle; + gMotion[i] = motion; + return; + } + + if ((particle.packed & kFlagAsleep) != 0u) { + // wake up when no floor + bool supported = false; + for (uint bit = 0u; bit < 8u && !supported; ++bit) { + if (((mask >> bit) & 1u) != 0u) { + float3 below = particle.position + rotateByQuat(particle.rotation, bitOffset(bit)) - float3(0.0, 0.0, gParams.voxelSize); + supported = contactAt(below).hit; + } + } + + if (supported) { + gCurrent[i] = particle; + gMotion[i] = motion; + appendVoxels(i, mask); + return; + } + particle.packed &= ~kFlagAsleep; + motion.restSteps = 0u; + } + + bool touching = false; + for (uint s = 0u; s < gParams.substeps; ++s) { + float dt = gParams.dt; + particle.velocity += gParams.gravity * dt; + particle.position += particle.velocity * dt; + + float spin = length(motion.angular); + if (spin > 1e-5) { + particle.rotation = normalize(particle.rotation + 0.5 * dt * quatMul(float4(motion.angular, 0.0), particle.rotation)); + } + + // oone response per substep + Contact deepest; + deepest.hit = false; + deepest.normal = float3(0.0, 0.0, 1.0); + deepest.depth = 0.0; + for (uint bit = 0u; bit < 8u; ++bit) { + if (((mask >> bit) & 1u) == 0u) { + continue; + } + Contact contact = contactAt(particle.position + rotateByQuat(particle.rotation, bitOffset(bit))); + if (contact.hit && (!deepest.hit || contact.depth > deepest.depth)) { + deepest = contact; + } + } + + if (deepest.hit) { + touching = true; + particle.position += deepest.normal * (deepest.depth + kContactSlop); + + float approach = dot(particle.velocity, deepest.normal); + if (approach < 0.0) { + float3 tangent = particle.velocity - approach * deepest.normal; + float bounce = -approach > kBounceSpeed ? -approach * gParams.restitution : 0.0; + particle.velocity = tangent * saturate(1.0 - gParams.friction) + bounce * deepest.normal; + motion.angular *= saturate(1.0 - gParams.friction); + } + } + } + + if (touching && length(particle.velocity) < kSleepSpeed) { + ++motion.restSteps; + } else { + motion.restSteps = 0u; + } + + if (motion.restSteps >= kSleepSteps) { + particle.velocity = float3(0.0, 0.0, 0.0); + motion.angular = float3(0.0, 0.0, 0.0); + particle.packed |= kFlagAsleep; + } + + gCurrent[i] = particle; + gMotion[i] = motion; + appendVoxels(i, mask); +} + +[shader("compute")] +[numthreads(kGroupSize, 1, 1)] +void spawnMain(uint3 id : SV_DispatchThreadID) { + uint j = id.x; + if (j >= gParams.spawnCount) { + return; + } + + DebrisSpawn spawn = gSpawns[j]; + if (spawn.slot >= gParams.capacity) { + return; + } + + DebrisParticle particle; + particle.position = spawn.position; + particle.packed = spawn.packed & ~kFlagAsleep; + particle.rotation = spawn.rotation; + particle.velocity = spawn.velocity; + particle.albedo = spawn.albedo; + gCurrent[spawn.slot] = particle; + + DebrisMotion motion; + motion.angular = spawn.angular; + motion.age = 0.0; + motion.restSteps = 0u; + motion.pad0 = 0u; + motion.pad1 = 0u; + motion.pad2 = 0u; + gMotion[spawn.slot] = motion; + + appendVoxels(spawn.slot, spawn.packed & kMaskBits); +} diff --git a/engine/assets/shaders/debris.slang.meta b/engine/assets/shaders/debris.slang.meta new file mode 100644 index 000000000..c94d717a6 --- /dev/null +++ b/engine/assets/shaders/debris.slang.meta @@ -0,0 +1,4 @@ +uid = "D3br1sS1mQg" +type = "shader" +created_at = "2026-10-19T12:00:00.0000000Z" +modified_at = "2026-10-19T12:00:00.0000000Z" diff --git a/engine/assets/shaders/debris_draw.slang b/engine/assets/shaders/debris_draw.slang new file mode 100644 index 000000000..856af2761 --- /dev/null +++ b/engine/assets/shaders/debris_draw.slang @@ -0,0 +1,137 @@ +// Draws debris into the voxel G-buffer so they are like every other voxel +// I think this works maybe dario should review it +import voxel_gbuffer; +import lighting; + +struct PushConstants { + float extrapolation; + float frameDelta; + float voxelSize; + uint listCapacity; +}; + +[[vk::push_constant]] +PushConstants gPush; + +struct DebrisParticle { + float3 position; + uint packed; + float4 rotation; + float3 velocity; + uint albedo; +}; + +[[vk::binding(0, 1)]] +StructuredBuffer gParticles; +[[vk::binding(1, 1)]] +StructuredBuffer gDrawList; + +static const float3 kDebrisBoxCorners[8] = { + float3(0.0, 0.0, 0.0), float3(1.0, 0.0, 0.0), float3(1.0, 1.0, 0.0), float3(0.0, 1.0, 0.0), + float3(0.0, 0.0, 1.0), float3(1.0, 0.0, 1.0), float3(1.0, 1.0, 1.0), float3(0.0, 1.0, 1.0), +}; + +static const uint kDebrisBoxIndices[36] = { + 0, 2, 1, 0, 3, 2, // -Z + 4, 5, 6, 4, 6, 7, // +Z + 0, 1, 5, 0, 5, 4, // -Y + 3, 7, 6, 3, 6, 2, // +Y + 0, 4, 7, 0, 7, 3, // -X + 1, 2, 6, 1, 6, 5, // +X +}; + +static const float3 kDebrisFaceNormals[6] = { + float3(0.0, 0.0, -1.0), float3(0.0, 0.0, 1.0), float3(0.0, -1.0, 0.0), + float3(0.0, 1.0, 0.0), float3(-1.0, 0.0, 0.0), float3(1.0, 0.0, 0.0), +}; + +struct VSOutput { + float4 position : SV_Position; + + [[vk::location(0)]] + float3 worldPosition : TEXCOORD0; + + [[vk::location(1)]] + float3 previousWorldPosition : TEXCOORD1; + + [[vk::location(2)]] + nointerpolation float3 normal : TEXCOORD2; + + [[vk::location(3)]] + nointerpolation uint albedo : TEXCOORD3; + + [[vk::location(4)]] + nointerpolation uint packed : TEXCOORD4; +}; + +float3 debrisRotate(float4 q, float3 v) { + const float3 t = 2.0 * cross(q.xyz, v); + return v + q.w * t + cross(q.xyz, t); +} + +float3 debrisSrgbToLinear(float3 value) { + return lerp(pow((value + 0.055) / 1.055, 2.4), value / 12.92, step(value, float3(0.04045, 0.04045, 0.04045))); +} + +[shader("vertex")] +VSOutput vertexMain(uint vertexId : SV_VertexID, uint instanceId : SV_InstanceID) { + VSOutput output; + output.position = float4(0.0, 0.0, 0.0, 1.0); + output.worldPosition = float3(0.0, 0.0, 0.0); + output.previousWorldPosition = float3(0.0, 0.0, 0.0); + output.normal = float3(0.0, 0.0, 1.0); + output.albedo = 0u; + output.packed = 0u; + + if (instanceId >= gPush.listCapacity) { + return output; + } + + uint entry = gDrawList[instanceId]; + DebrisParticle particle = gParticles[entry >> 3]; + uint bit = entry & 7u; + if (((particle.packed >> bit) & 1u) == 0u) { + return output; + } + + // The block spans one voxel either side of its center + float3 corner = kDebrisBoxCorners[kDebrisBoxIndices[vertexId]]; + float3 cell = float3(float(bit & 1u), float((bit >> 1) & 1u), float(bit >> 2)); + float3 local = (cell - 1.0 + corner) * gPush.voxelSize; + + float3 center = particle.position + particle.velocity * gPush.extrapolation; + float3 world = center + debrisRotate(particle.rotation, local); + + output.position = mul(gCamera.jitteredViewProjection, float4(world, 1.0)); + output.worldPosition = world; + output.previousWorldPosition = world - particle.velocity * gPush.frameDelta; + output.normal = debrisRotate(particle.rotation, kDebrisFaceNormals[vertexId / 6u]); + output.albedo = particle.albedo; + output.packed = particle.packed; + return output; +} + +struct FSOutput { + float4 albedo : SV_Target0; + float4 faceNormal : SV_Target1; + float4 material : SV_Target2; + float4 motion : SV_Target3; +}; + +[shader("fragment")] +FSOutput fragmentMain(VSOutput input) { + float3 albedo = debrisSrgbToLinear(float3(input.albedo & 0xFFu, (input.albedo >> 8) & 0xFFu, (input.albedo >> 16) & 0xFFu) / 255.0); + float roughness = clamp(float((input.albedo >> 24) & 0xFFu) / 255.0, 0.045, 1.0); + float metallic = float((input.packed >> 8) & 0xFFu) / 255.0; + float reflectivity = float((input.packed >> 16) & 0xFFu) / 255.0; + + float4 clip = mul(gCamera.viewProjection, float4(input.worldPosition, 1.0)); + float4 previousClip = mul(gCamera.previousViewProjection, float4(input.previousWorldPosition, 1.0)); + + FSOutput output; + output.albedo = float4(albedo, kGbufferLit); + output.faceNormal = encodeFaceNormal(normalize(input.normal)); + output.material = float4(metallic, reflectivity, roughness, encodeEmissive(0.0)); + output.motion = float4((clip.xy / clip.w - previousClip.xy / previousClip.w) * 0.5, 0.0, 1.0); + return output; +} diff --git a/engine/assets/shaders/debris_draw.slang.meta b/engine/assets/shaders/debris_draw.slang.meta new file mode 100644 index 000000000..552b3fb9e --- /dev/null +++ b/engine/assets/shaders/debris_draw.slang.meta @@ -0,0 +1,4 @@ +uid = "D3br1sDr4wQ" +type = "shader" +created_at = "2026-10-19T12:00:00.0000000Z" +modified_at = "2026-10-19T12:00:00.0000000Z" diff --git a/engine/src/toast/engine.cpp b/engine/src/toast/engine.cpp index 3e350214b..25b4ea516 100644 --- a/engine/src/toast/engine.cpp +++ b/engine/src/toast/engine.cpp @@ -14,6 +14,7 @@ #include "input/input_events.hpp" #include "input/input_system.hpp" #include "logger.hpp" +#include "physics/debris_simulation.hpp" #include "physics/physics_settings.hpp" #include "physics/simulator.hpp" #include "profiler_sections.hpp" @@ -21,6 +22,7 @@ #include "reflect/reflect.hpp" #include "renderer/passes/bloom_pass.hpp" #include "renderer/passes/cluster_lighting_pass.hpp" +#include "renderer/passes/debris_pass.hpp" #include "renderer/passes/debug_pass.hpp" #include "renderer/passes/environment_pass.hpp" #include "renderer/passes/fxaa_pass.hpp" @@ -87,6 +89,15 @@ IApplication* active_application = nullptr; double clear_assets_timer = 0.0; double script_reload_timer = 0.0; + +void addDebrisPass( + const renderer::VulkanCore& core, renderer::VulkanRenderer& renderer, vk::Format depth_format, vk::Extent2D extent +) { + auto debris = std::make_shared(physics::tunables().debris_max_particles); + auto debris_pass = std::make_unique(core, depth_format, extent, std::move(debris)); + renderer.setDebrisPass(debris_pass.get()); + renderer.addRenderPass(std::move(debris_pass)); +} } Engine* Engine::instance = nullptr; @@ -626,6 +637,7 @@ void Engine::createSDLWindow(const char* w_name) { auto voxel_pass = std::make_unique(*m->vulkan_core, scene_format, depth_format, extent); m->renderer->setVoxelPass(voxel_pass.get()); m->renderer->addRenderPass(std::move(voxel_pass)); + addDebrisPass(*m->vulkan_core, *m->renderer, depth_format, extent); // World-space UI panels are scene content and get exposed with it, the screen-space UI does not m->renderer->addRenderPass(std::make_unique(*m->vulkan_core, scene_format, depth_format, extent)); @@ -715,6 +727,7 @@ void Engine::createAvaloniaWindow() { auto voxel_pass = std::make_unique(*m->vulkan_core, scene_format, depth_format, extent); m->renderer->setVoxelPass(voxel_pass.get()); m->renderer->addRenderPass(std::move(voxel_pass)); + addDebrisPass(*m->vulkan_core, *m->renderer, depth_format, extent); // World-space UI panels are scene content m->renderer->addRenderPass(std::make_unique(*m->vulkan_core, scene_format, depth_format, extent)); diff --git a/engine/src/toast/hash.hpp b/engine/src/toast/hash.hpp new file mode 100644 index 000000000..7a0069418 --- /dev/null +++ b/engine/src/toast/hash.hpp @@ -0,0 +1,31 @@ +/** + * @file hash.h + * @author Xein + * @date 16 Oct 2026 + * @brief splitmix64 hashing + */ + +#pragma once + +namespace toast { + +inline constexpr uint64_t k_golden_gamma = 0x9e3779b97f4a7c15ull; + +[[nodiscard]] +constexpr auto splitmix64(uint64_t value) noexcept -> uint64_t { + value = (value ^ (value >> 30)) * 0xbf58476d1ce4e5b9ull; + value = (value ^ (value >> 27)) * 0x94d049bb133111ebull; + return value ^ (value >> 31); +} + +constexpr void hashCombine(uint64_t& seed, uint64_t value) noexcept { + seed = splitmix64(value + k_golden_gamma + seed); +} + +constexpr void hashFloats(uint64_t& seed, const float* data, size_t count) noexcept { + for (size_t i = 0; i < count; ++i) { + hashCombine(seed, std::bit_cast(data[i])); + } +} + +} diff --git a/engine/src/toast/physics/damage_command.hpp b/engine/src/toast/physics/damage_command.hpp index 072c6f680..eaf0934b8 100644 --- a/engine/src/toast/physics/damage_command.hpp +++ b/engine/src/toast/physics/damage_command.hpp @@ -47,6 +47,7 @@ struct DamageCommand { glm::vec3 direction {}; Hardness max_hardness = k_hardest; BodyID source; + uint64_t seed = 0; float shell_voxels = tunables().fracture_shell_voxels; std::optional push; diff --git a/engine/src/toast/physics/debris.hpp b/engine/src/toast/physics/debris.hpp new file mode 100644 index 000000000..d0e96e41b --- /dev/null +++ b/engine/src/toast/physics/debris.hpp @@ -0,0 +1,170 @@ +/** + * @file debris.hpp + * @author Xein + * @date 17 Oct 2026 + * @brief Small voxel debris clusters + */ + +#pragma once + +#include "fracture_random.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace physics { + +struct DebrisVoxel { + glm::ivec3 voxel {0}; + uint8_t material = 0; +}; + +struct DebrisCluster { + glm::ivec3 origin {0}; + uint8_t mask = 0; + uint8_t material = 0; +}; + +struct DebrisSpawn { + glm::vec3 position {}; + glm::quat rotation {1.0f, 0.0f, 0.0f, 0.0f}; + glm::vec3 velocity {}; + glm::vec3 angular_velocity {}; + uint8_t mask = 0; + uint8_t material = 0; + uint32_t albedo_roughness = 0; + uint8_t metallic = 0; + uint8_t reflectivity = 0; +}; + +struct DebrisBlast { + glm::vec3 start {}; + glm::vec3 end {}; + float energy = 0.0f; +}; + +inline constexpr float k_max_debris_speed = 40.0f; +inline constexpr float k_debris_cone_spread = 0.5f; +inline constexpr float k_debris_max_spin = 8.0f; + +[[nodiscard]] +constexpr auto debrisBit(const glm::ivec3& local) -> uint32_t { + return static_cast(local.x + (2 * local.y) + (4 * local.z)); +} + +[[nodiscard]] +inline auto clusterDebris(std::span voxels, const glm::ivec3& jitter, Rng& rng, float split_chance) + -> std::vector { + struct Entry { + glm::ivec3 cell; + uint32_t bit; + uint8_t material; + }; + + std::vector entries; + entries.reserve(voxels.size()); + for (const DebrisVoxel& v : voxels) { + const glm::ivec3 shifted = v.voxel - jitter; + const glm::ivec3 cell {shifted.x >> 1, shifted.y >> 1, shifted.z >> 1}; + entries.push_back({.cell = cell, .bit = debrisBit(shifted - (cell * 2)), .material = v.material}); + } + std::ranges::sort(entries, [](const Entry& a, const Entry& b) { + if (a.cell.z != b.cell.z) { + return a.cell.z < b.cell.z; + } + if (a.cell.y != b.cell.y) { + return a.cell.y < b.cell.y; + } + if (a.cell.x != b.cell.x) { + return a.cell.x < b.cell.x; + } + return a.bit < b.bit; + }); + + std::vector out; + for (size_t first = 0; first < entries.size();) { + size_t last = first; + std::array material {}; + uint32_t present = 0; + while (last < entries.size() && entries[last].cell == entries[first].cell) { + present |= 1u << entries[last].bit; + material[entries[last].bit] = entries[last].material; + ++last; + } + + // a block sometimes breaks into 2 or 3 smaller pieces + const auto count = static_cast(std::popcount(present)); + uint32_t parts = 1; + if (count > 1 && rng.chance(split_chance)) { + parts = (count > 2 && rng.chance(0.5f)) ? 3u : 2u; + } + std::array part_masks {}; + for (uint32_t bit = 0; bit < 8; ++bit) { + if ((present & (1u << bit)) != 0) { + part_masks[parts == 1 ? 0 : rng.below(parts)] |= 1u << bit; + } + } + + for (uint32_t p = 0; p < parts; ++p) { + if (part_masks[p] == 0) { + continue; + } + // most common material of the part + uint8_t best = 0; + int best_count = 0; + for (uint32_t bit = 0; bit < 8; ++bit) { + if ((part_masks[p] & (1u << bit)) == 0) { + continue; + } + int same = 0; + for (uint32_t other = 0; other < 8; ++other) { + same += ((part_masks[p] & (1u << other)) != 0 && material[other] == material[bit]) ? 1 : 0; + } + if (same > best_count) { + best_count = same; + best = material[bit]; + } + } + out.push_back( + DebrisCluster { + .origin = (entries[first].cell * 2) + jitter, + .mask = static_cast(part_masks[p]), + .material = best, + } + ); + } + first = last; + } + return out; +} + +[[nodiscard]] +inline auto debrisSpeed(float energy, float mass, float scale) -> float { + if (not(energy > 0.0f) || not(mass > 0.0f) || not(scale > 0.0f)) { + return 0.0f; + } + return std::min(std::sqrt(2.0f * energy / mass) * scale, k_max_debris_speed); +} + +[[nodiscard]] +inline auto debrisConeDirection(const glm::vec3& axis, Rng& rng, float spread) -> glm::vec3 { + const glm::vec3 tilted = axis + (rng.direction() * spread); + const float length = glm::length(tilted); + return length > 1.0e-4f ? tilted / length : axis; +} + +TOAST_API void stepDebris(size_t steps); + +TOAST_API void clearDebris(); + +} diff --git a/engine/src/toast/physics/debris_simulation.cpp b/engine/src/toast/physics/debris_simulation.cpp new file mode 100644 index 000000000..2eaf11b2b --- /dev/null +++ b/engine/src/toast/physics/debris_simulation.cpp @@ -0,0 +1,246 @@ +#include "debris_simulation.hpp" + +#include "physics_settings.hpp" +#include "simulator.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace physics { + +namespace { + +std::atomic g_debris_simulation {nullptr}; + +constexpr uint32_t k_debris_group_size = 64; +constexpr uint32_t k_debris_max_substeps = 4; +constexpr uint32_t k_debris_reset_entry = 0; +constexpr uint32_t k_debris_integrate_entry = 1; +constexpr uint32_t k_debris_spawn_entry = 2; +constexpr double k_debris_min_extrapolation = -0.1; +constexpr double k_debris_max_extrapolation = 0.25; + +[[nodiscard]] +auto debrisGroups(uint32_t count) -> uint32_t { + return (count + k_debris_group_size - 1) / k_debris_group_size; +} + +} + +DebrisSimulation::DebrisSimulation(uint32_t capacity) + : AsyncComputeTask({ + .shader_uri = "core://shaders/debris.slang", + .entries = {"resetMain", "integrateMain", "spawnMain"}, + .debug_name = "Debris" +}), + m_capacity(std::max(capacity, 1u)) { + ZoneScoped; + + // motion is always on this shader + m_particles = addRingBuffer(sizeof(GpuParticle) * m_capacity); + m_motion = addPersistentBuffer(sizeof(GpuMotion) * m_capacity); + m_draw_list = addRingBuffer(sizeof(uint32_t) * m_capacity * k_voxels_per_particle); + m_draw_args = addRingBuffer(sizeof(uint32_t) * 4); + m_spawns = addHostBuffer(sizeof(GpuSpawn) * k_max_spawns_per_step); + m_volumes = addHostBuffer(sizeof(GpuVolume) * k_max_volumes); + setAcquireUsage(m_draw_args, renderer::sync::Usage::indirect_read); + + bind(0, m_particles, Copy::previous); + bind(1, m_particles, Copy::current); + bind(2, m_motion); + bind(3, m_draw_list); + bind(4, m_draw_args); + bind(5, m_spawns); + bind(6, m_volumes); + + const auto section = [this](renderer::VoxelGpuStorage::Section which) { + return [this, which]() -> vk::Buffer { + return m_world != nullptr && m_world->storage != nullptr ? m_world->storage->buffer(which) : vk::Buffer {}; + }; + }; + bindExternal(1, 0, section(renderer::VoxelGpuStorage::Section::materials)); + bindExternal(1, 2, section(renderer::VoxelGpuStorage::Section::grids)); + bindExternal(1, 5, section(renderer::VoxelGpuStorage::Section::records)); + + if (isReady()) { + // first step reads the previous copy + const std::vector particles(m_capacity); + const std::vector motion(m_capacity); + upload(m_particles, std::span(particles)); + upload(m_motion, std::span(motion)); + } else { + TOAST_ERROR("Physics", "Debris simulation unavailable, debris will be dropped"); + } + + g_debris_simulation.store(this, std::memory_order_release); +} + +DebrisSimulation::~DebrisSimulation() { + DebrisSimulation* expected = this; + g_debris_simulation.compare_exchange_strong(expected, nullptr, std::memory_order_acq_rel); +} + +auto DebrisSimulation::get() -> DebrisSimulation* { + return g_debris_simulation.load(std::memory_order_acquire); +} + +void DebrisSimulation::spawn(std::span spawns) { + for (const DebrisSpawn& spawn : spawns) { + if (spawn.mask == 0) { + continue; + } + m_pending.push_back( + GpuSpawn { + .position = spawn.position, + .slot = m_cursor, + .rotation = {spawn.rotation.x, spawn.rotation.y, spawn.rotation.z, spawn.rotation.w}, + .velocity = spawn.velocity, + .albedo_roughness = spawn.albedo_roughness, + .angular_velocity = spawn.angular_velocity, + .packed = static_cast(spawn.mask) | (static_cast(spawn.metallic) << 8u) | + (static_cast(spawn.reflectivity) << 16u), + } + ); + m_cursor = (m_cursor + 1) % m_capacity; + } + // Anything older would be overwritten before drawn + while (m_pending.size() > m_capacity) { + m_pending.pop_front(); + } +} + +void DebrisSimulation::step(uint32_t steps, double fixed_delta, std::shared_ptr world) { + ZoneScopedN("physics::DebrisStep"); + if (steps == 0 || !isReady()) { + m_pending.clear(); + return; + } + + const auto spawn_count = static_cast(std::min(m_pending.size(), k_max_spawns_per_step)); + const auto volume_count = world != nullptr ? static_cast(std::min(world->volumes.size(), k_max_volumes)) : 0u; + const double total = static_cast(steps) * fixed_delta; + const uint32_t substeps = std::min(steps, k_debris_max_substeps); + + const Tunables& settings = tunables(); + const Params params { + .gravity = settings.gravity, + .dt = static_cast(total / static_cast(substeps)), + .lifetime = settings.debris_lifetime, + .restitution = settings.debris_restitution, + .friction = settings.debris_friction, + .voxel_size = voxel::k_voxel_size, + .capacity = m_capacity, + .spawn_count = spawn_count, + .volume_count = volume_count, + .substeps = substeps, + .clear = m_clear_pending ? 1u : 0u, + }; + + m_world = world; + const uint64_t step_id = AsyncComputeTask::step( + [&](StepContext& ctx) { + const std::span spawns = ctx.hostData(m_spawns); + std::copy_n(m_pending.begin(), std::min(spawn_count, spawns.size()), spawns.begin()); + + const std::span volumes = ctx.hostData(m_volumes); + for (uint32_t i = 0; i < volume_count && i < volumes.size(); ++i) { + const renderer::VoxelCollisionWorld::Volume& volume = world->volumes[i]; + volumes[i] = GpuVolume { + .world_to_voxel = volume.world_to_voxel, + .bounds = glm::vec4(volume.bounds_center, volume.bounds_radius), + .record_index = volume.record_index, + }; + } + + ctx.dispatch(1, params, k_debris_reset_entry); + ctx.dispatch(debrisGroups(m_capacity), params, k_debris_integrate_entry); + if (spawn_count > 0) { + ctx.dispatch(debrisGroups(spawn_count), params, k_debris_spawn_entry); + } + }, + "Debris", + world + ); + m_world.reset(); + + if (step_id == 0) { + return; + } + m_pending.erase(m_pending.begin(), m_pending.begin() + spawn_count); + m_clear_pending = false; + + std::scoped_lock lock(m_time_mutex); + m_sim_time += total; + m_step_times[step_id % k_step_history] = m_sim_time; + m_visible = m_visible || spawn_count > 0; +} + +void DebrisSimulation::setDisplayAlpha(float alpha, double fixed_delta) { + std::scoped_lock lock(m_time_mutex); + const double display = m_sim_time - ((1.0 - static_cast(std::clamp(alpha, 0.0f, 1.0f))) * fixed_delta); + m_frame_delta = std::clamp(display - m_display_time, 0.0, k_debris_max_extrapolation); + m_display_time = display; +} + +void DebrisSimulation::clear() { + m_pending.clear(); + m_clear_pending = true; + std::scoped_lock lock(m_time_mutex); + m_visible = false; +} + +auto DebrisSimulation::visible() const -> bool { + std::scoped_lock lock(m_time_mutex); + return m_visible; +} + +auto DebrisSimulation::extrapolation(uint64_t step) const -> float { + if (step == 0) { + return 0.0f; + } + std::scoped_lock lock(m_time_mutex); + const double behind = m_display_time - m_step_times[step % k_step_history]; + return static_cast(std::clamp(behind, k_debris_min_extrapolation, k_debris_max_extrapolation)); +} + +auto DebrisSimulation::frameDelta() const -> float { + std::scoped_lock lock(m_time_mutex); + return static_cast(m_frame_delta); +} + +void stepDebris(size_t steps) { + ZoneScopedN("physics::StepDebris"); + Simulator* simulator = Simulator::current(); + const std::vector spawns = simulator != nullptr ? simulator->takeDebrisSpawns() : std::vector {}; + + DebrisSimulation* debris = DebrisSimulation::get(); + if (debris == nullptr) { + return; + } + debris->spawn(spawns); + + const double fixed_delta = tunables().fixedDelta(); + if (steps > 0) { + const renderer::VulkanRenderer* renderer = renderer::VulkanRenderer::instance; + debris->step(static_cast(steps), fixed_delta, renderer != nullptr ? renderer->voxelCollisionWorld() : nullptr); + } + debris->setDisplayAlpha(Simulator::interpolationAlpha(), fixed_delta); +} + +void clearDebris() { + if (Simulator* simulator = Simulator::current(); simulator != nullptr) { + auto discarded = simulator->takeDebrisSpawns(); + } + if (DebrisSimulation* debris = DebrisSimulation::get(); debris != nullptr) { + debris->clear(); + } +} + +} diff --git a/engine/src/toast/physics/debris_simulation.hpp b/engine/src/toast/physics/debris_simulation.hpp new file mode 100644 index 000000000..639c9680b --- /dev/null +++ b/engine/src/toast/physics/debris_simulation.hpp @@ -0,0 +1,158 @@ +/** + * @file debris_simulation.hpp + * @author Xein + * @date 19 Oct 2026 + * @brief Small voxel debris + */ + +#pragma once + +#include "debris.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace renderer { +struct VoxelCollisionWorld; +} + +namespace physics { + +class TOAST_API DebrisSimulation : public renderer::AsyncComputeTask { +public: + struct GpuParticle { + glm::vec3 position {0.0f}; + uint32_t packed = 0; + glm::vec4 rotation {0.0f, 0.0f, 0.0f, 1.0f}; + glm::vec3 velocity {0.0f}; + uint32_t albedo_roughness = 0; + }; + + struct GpuMotion { + glm::vec3 angular_velocity {0.0f}; + float age = 0.0f; + uint32_t rest_steps = 0; + std::array pad {}; + }; + + struct GpuSpawn { + glm::vec3 position {0.0f}; + uint32_t slot = 0; + glm::vec4 rotation {0.0f, 0.0f, 0.0f, 1.0f}; + glm::vec3 velocity {0.0f}; + uint32_t albedo_roughness = 0; + glm::vec3 angular_velocity {0.0f}; + uint32_t packed = 0; + }; + + struct GpuVolume { + glm::mat4 world_to_voxel {1.0f}; + glm::vec4 bounds {0.0f}; + uint32_t record_index = 0; + std::array pad {}; + }; + + // just in case somebody touches this + static_assert(sizeof(GpuParticle) == 48, "GpuParticle is mirrored by debris.slang"); + static_assert(sizeof(GpuMotion) == 32, "GpuMotion is mirrored by debris.slang"); + static_assert(sizeof(GpuSpawn) == 64, "GpuSpawn is mirrored by debris.slang"); + static_assert(sizeof(GpuVolume) == 96, "GpuVolume is mirrored by debris.slang"); + + static constexpr uint32_t k_max_spawns_per_step = 4096; + static constexpr uint32_t k_max_volumes = 512; + static constexpr uint32_t k_voxels_per_particle = 8; + + explicit DebrisSimulation(uint32_t capacity); + ~DebrisSimulation() override; + + DebrisSimulation(const DebrisSimulation&) = delete; + auto operator=(const DebrisSimulation&) -> DebrisSimulation& = delete; + DebrisSimulation(DebrisSimulation&&) = delete; + auto operator=(DebrisSimulation&&) -> DebrisSimulation& = delete; + + [[nodiscard]] + static auto get() -> DebrisSimulation*; + void spawn(std::span spawns); + void step(uint32_t steps, double fixed_delta, std::shared_ptr world); + void setDisplayAlpha(float alpha, double fixed_delta); + void clear(); + + [[nodiscard]] + auto capacity() const -> uint32_t { + return m_capacity; + } + + [[nodiscard]] + auto visible() const -> bool; + + [[nodiscard]] + auto extrapolation(uint64_t step) const -> float; // render thread + + [[nodiscard]] + auto frameDelta() const -> float; // render thread + + [[nodiscard]] + auto particlesId() const -> BufferId { + return m_particles; + } + + [[nodiscard]] + auto drawListId() const -> BufferId { + return m_draw_list; + } + + [[nodiscard]] + auto drawArgsId() const -> BufferId { + return m_draw_args; + } + +private: + struct Params { + glm::vec3 gravity {0.0f}; + float dt = 0.0f; + float lifetime = 0.0f; + float restitution = 0.0f; + float friction = 0.0f; + float voxel_size = 0.0f; + uint32_t capacity = 0; + uint32_t spawn_count = 0; + uint32_t volume_count = 0; + uint32_t substeps = 1; + uint32_t clear = 0; + std::array pad {}; + }; + + static_assert(sizeof(Params) == 64, "Params is mirrored by debris.slang"); + + static constexpr uint64_t k_step_history = 16; + + uint32_t m_capacity = 0; + uint32_t m_cursor = 0; + + BufferId m_particles = 0; + BufferId m_motion = 0; + BufferId m_draw_list = 0; + BufferId m_draw_args = 0; + BufferId m_spawns = 0; + BufferId m_volumes = 0; + + std::deque m_pending; + bool m_clear_pending = false; + + std::shared_ptr m_world; + + mutable std::mutex m_time_mutex; + double m_sim_time = 0.0; + double m_display_time = 0.0; + double m_frame_delta = 0.0; + std::array m_step_times {}; + bool m_visible = false; +}; + +} diff --git a/engine/src/toast/physics/destruction_material.cpp b/engine/src/toast/physics/destruction_material.cpp index 1db81dfaa..9bf223ec4 100644 --- a/engine/src/toast/physics/destruction_material.cpp +++ b/engine/src/toast/physics/destruction_material.cpp @@ -2,6 +2,8 @@ #include "material_defaults.hpp" +#include +#include #include #include #include @@ -19,6 +21,8 @@ void assets::DestructionMaterial::readFields() { m_density = physics::material_defaults::k_density; m_toughness = physics::material_defaults::k_toughness; m_hardness = physics::material_defaults::k_hardness; + m_fracture_pattern = physics::material_defaults::k_fracture_pattern; + m_fracture_values = physics::FractureValues {}; m_structural_strength = physics::material_defaults::k_structural_strength; m_destructible = true; m_flammable = false; @@ -38,6 +42,50 @@ void assets::DestructionMaterial::readFields() { TOAST_WARN("DestructionMaterial", "Unknown hardness '{}', using '{}'", name, physics::hardnessName(m_hardness)); } } + if (d.contains("fracture_pattern")) { + const std::string name = d["fracture_pattern"].as(); + if (std::optional fracture_pattern = physics::fracturePatternFromName(name)) { + m_fracture_pattern = *fracture_pattern; + } else { + TOAST_WARN( + "DestructionMaterial", + "Unknown fracture pattern '{}', using '{}'", + name, + physics::fracturePatternName(m_fracture_pattern) + ); + } + } + const auto group = [&d](const char* name) -> const DataValue* { + return d.contains(name) && d[name].isObject() ? &d[name] : nullptr; + }; + const auto read_float = [](const DataValue* table, const char* key, float& value) { + if (table != nullptr && table->contains(key)) { + value = (*table)[key].as(); + } + }; + const auto read_count = [](const DataValue* table, const char* key, uint32_t& value) { + if (table != nullptr && table->contains(key)) { + value = static_cast(std::max((*table)[key].as(), 0)); + } + }; + physics::FractureValues& values = m_fracture_values; + const DataValue* energy = group("energy"); + read_float(energy, "debris_share", values.debris_share); + read_float(energy, "fragment_share", values.fragment_share); + const DataValue* fragment = group("fragment"); + read_float(fragment, "threshold", values.fragment_threshold); + read_count(fragment, "max_parts", values.fragment_max_parts); + const DataValue* shatter = group("shatter"); + read_float(shatter, "threshold", values.shatter_threshold); + read_float(shatter, "cell_size", values.shatter_cell_size); + read_float(shatter, "crack_cost", values.shatter_crack_cost); + read_float(shatter, "release", values.shatter_release); + read_float(shatter, "stretch", values.shatter_stretch); + read_float(shatter, "crumble", values.shatter_crumble); + const DataValue* edge = group("edge"); + read_float(edge, "noise", values.edge_noise); + read_float(edge, "lobes", values.edge_lobes); + read_float(edge, "noise_scale", values.edge_noise_scale); if (d.contains("structural_strength")) { m_structural_strength = d["structural_strength"].as(); } @@ -68,6 +116,14 @@ auto assets::DestructionMaterial::hardness() const noexcept -> physics::Hardness return m_hardness; } +auto assets::DestructionMaterial::fracturePatterh() const noexcept -> physics::FracturePattern { + return m_fracture_pattern; +} + +auto assets::DestructionMaterial::fractureProfile() const noexcept -> physics::PackedFractureProfile { + return physics::PackedFractureProfile::pack(m_fracture_values); +} + auto assets::DestructionMaterial::structuralStrength() const noexcept -> float { return m_structural_strength; } diff --git a/engine/src/toast/physics/destruction_material.hpp b/engine/src/toast/physics/destruction_material.hpp index ca7bc3934..28c0a2215 100644 --- a/engine/src/toast/physics/destruction_material.hpp +++ b/engine/src/toast/physics/destruction_material.hpp @@ -7,6 +7,9 @@ */ #pragma once +#include "fracture_pattern.hpp" +#include "fracture_profile.hpp" + #include #include @@ -36,6 +39,12 @@ class TOAST_API DestructionMaterial : public Data { [[nodiscard]] auto hardness() const noexcept -> physics::Hardness; + [[nodiscard]] + auto fracturePatterh() const noexcept -> physics::FracturePattern; + + [[nodiscard]] + auto fractureProfile() const noexcept -> physics::PackedFractureProfile; + /// @brief Pascals [[nodiscard]] auto structuralStrength() const noexcept -> float; @@ -59,6 +68,8 @@ class TOAST_API DestructionMaterial : public Data { float m_density = 0.0f; float m_toughness = 0.0f; physics::Hardness m_hardness = physics::Hardness::medium; + physics::FracturePattern m_fracture_pattern = physics::FracturePattern::brittle; + physics::FractureValues m_fracture_values; float m_structural_strength = 0.0f; bool m_destructible = true; bool m_flammable = false; diff --git a/engine/src/toast/physics/fracture.hpp b/engine/src/toast/physics/fracture.hpp index fe6c7ff24..ef3f92348 100644 --- a/engine/src/toast/physics/fracture.hpp +++ b/engine/src/toast/physics/fracture.hpp @@ -7,6 +7,9 @@ #pragma once +#include "fracture_pattern.hpp" +#include "fracture_profile.hpp" +#include "fracture_random.hpp" #include "hardness.hpp" #include "physics_settings.hpp" @@ -42,6 +45,7 @@ struct FractureRequest { float radius = 0.0f; float energy_density = 0.0f; Hardness max_hardness = k_hardest; + uint64_t seed = 0; float depth_share = tunables().fracture_depth_share; float max_depth = tunables().fracture_max_depth; float max_overshoot = tunables().fracture_max_overshoot; @@ -74,8 +78,47 @@ struct Crater { float radius = 0.0f; bool breaks = false; UnglueRegion unglue; + std::unordered_set reachable; + uint64_t seed = 0; + float edge_noise = 0.0f; + float edge_noise_scale = 0.0f; + float edge_lobes = 0.0f; + FracturePattern pattern = FracturePattern::brittle; + FractureValues profile; + float toughness = 0.0f; + float fragment_energy = 0.0f; + float shatter_energy = 0.0f; + float debris_energy = 0.0f; }; +inline constexpr float k_max_edge_noise = 0.9f; +inline constexpr float k_min_radius_factor = 0.15f; + +[[nodiscard]] +inline auto craterRadiusAt(const Crater& crater, const glm::vec3& point) -> float { + float factor = 1.0f; + if (crater.edge_lobes > 0.0f && crater.radius > 0.0f) { + const float lobes = valueNoise3(streamSeed(crater.seed, FractureStream::edge_lobes), point / crater.radius); + factor += crater.edge_lobes * ((lobes * 2.0f) - 1.0f); + } + if (crater.edge_noise > 0.0f && crater.edge_noise_scale > 0.0f) { + const float noise = valueNoise3(streamSeed(crater.seed, FractureStream::edge_noise), point / crater.edge_noise_scale); + factor += crater.edge_noise * ((noise * 2.0f) - 1.0f); + } + return crater.radius * std::max(factor, k_min_radius_factor); +} + +[[nodiscard]] +constexpr auto craterMaxRadius(const Crater& crater) -> float { + return crater.radius * (1.0f + std::max(crater.edge_noise, 0.0f) + std::max(crater.edge_lobes, 0.0f)); +} + +[[nodiscard]] +constexpr auto craterMinRadius(const Crater& crater) -> float { + return crater.radius * + std::max(1.0f - std::max(crater.edge_noise, 0.0f) - std::max(crater.edge_lobes, 0.0f), k_min_radius_factor); +} + struct FractureCandidate { float depth = 0.0f; float distance = 0.0f; @@ -177,6 +220,62 @@ inline auto reachesThroughMaterial(const voxel::Volume& volume, const glm::vec3& return true; } +[[nodiscard]] +inline auto reachableMaterial(const voxel::Volume& volume, const glm::vec3& start, const glm::vec3& end, float radius) + -> std::unordered_set { + std::unordered_set out; + + // voxel closest to the hit + std::optional seed; + float closest = radius * radius; + forEachSolidVoxel(volume, start - glm::vec3(radius), start + glm::vec3(radius), [&](glm::ivec3 v, glm::vec3 center, uint8_t) { + const glm::vec3 offset = center - start; + const float distance_squared = glm::dot(offset, offset); + if (distance_squared <= closest) { + closest = distance_squared; + seed = v; + } + }); + if (not seed.has_value()) { + return out; + } + + const glm::ivec3 dims = glm::ivec3(volume.voxelDims()); + std::vector stack {*seed}; + out.insert(voxelKey(*seed)); + while (not stack.empty()) { + const glm::ivec3 v = stack.back(); + stack.pop_back(); + for (int32_t z = -1; z <= 1; ++z) { + for (int32_t y = -1; y <= 1; ++y) { + for (int32_t x = -1; x <= 1; ++x) { + const glm::ivec3 next = v + glm::ivec3 {x, y, z}; + if (next == v || glm::any(glm::lessThan(next, glm::ivec3(0))) || glm::any(glm::greaterThanEqual(next, dims)) || + volume.materialAt(next) == voxel::k_empty_palette_index) { + continue; + } + const glm::vec3 center = (glm::vec3 {next} + 0.5f) * voxel::k_voxel_size; + if (pointSegmentDistanceSquared(center, start, end) > radius * radius) { + continue; + } + if (out.insert(voxelKey(next)).second) { + stack.push_back(next); + } + } + } + } + } + return out; +} + +[[nodiscard]] +inline auto damageReaches( + const std::unordered_set& reachable, const voxel::Volume& volume, const glm::ivec3& voxel, const glm::vec3& from, + const glm::vec3& center +) -> bool { + return reachable.contains(voxelKey(voxel)) || reachesThroughMaterial(volume, from, center); +} + [[nodiscard]] constexpr auto hitBreaks(const voxel::PhysicalMaterial& material, float energy_density, Hardness max_hardness) -> bool { return material.breakableBy(max_hardness) && material.toughness <= energy_density; @@ -207,7 +306,7 @@ inline auto hitMaterial( [[nodiscard]] inline auto fractureCandidates( const voxel::Volume& volume, const voxel::Palette& palette, const voxel::MaterialLibrary& materials, - const FractureRequest& request + const FractureRequest& request, const std::unordered_set& reachable ) -> std::vector { std::vector out; if (materials.materials.empty() || not(request.radius > 0.0f)) { @@ -221,7 +320,7 @@ inline auto fractureCandidates( const glm::vec3 high = glm::max(request.point, far) + glm::vec3(radius); const float axis_reach = 0.5f * std::sqrt(3.0f) * voxel::k_voxel_size; // half the diagonal of a voxel - forEachSolidVoxel(volume, low, high, [&](glm::ivec3, glm::vec3 center, uint8_t index) { + forEachSolidVoxel(volume, low, high, [&](glm::ivec3 voxel, glm::vec3 center, uint8_t index) { const glm::vec3 offset = center - request.point; const float distance = glm::length(offset); const voxel::PhysicalMaterial& material = materials.materials[voxel::resolveMaterialIndex(palette, materials, index)]; @@ -232,7 +331,7 @@ inline auto fractureCandidates( }; if (distance <= radius) { - if (reachesThroughMaterial(volume, request.point, center)) { + if (damageReaches(reachable, volume, voxel, request.point, center)) { out.push_back(candidate); } return; @@ -253,7 +352,7 @@ inline auto fractureCandidates( return; } candidate.on_axis = lateral_squared <= axis_reach * axis_reach; - if (reachesThroughMaterial(volume, request.point + (request.direction * along), center)) { + if (damageReaches(reachable, volume, voxel, request.point + (request.direction * along), center)) { out.push_back(candidate); } }); @@ -320,6 +419,37 @@ inline auto overshootRadius(float energy_left, float toughness, float radius, fl return low; } +struct EnergySplit { + float debris = 0.0f; + float fragment = 0.0f; + float shatter = 0.0f; + float overshoot = 0.0f; +}; + +[[nodiscard]] +inline auto splitLeftoverEnergy(float left, float debris_share, float fragment_share, FracturePattern pattern) -> EnergySplit { + if (not std::isfinite(left) || left <= 0.0f) { + return {}; + } + + auto sanitize = [](float share) { return std::isfinite(share) ? std::clamp(share, 0.0f, 1.0f) : 0.0f; }; + float debris = sanitize(debris_share); + float fragment = sanitize(fragment_share); + if (float total = debris + fragment; total > 1.0f) { + debris /= total; + fragment /= total; + } + + EnergySplit out {.debris = left * debris, .fragment = left * fragment}; + const float rest = std::max(left - out.debris - out.fragment, 0.0f); + if (pattern == FracturePattern::ductile) { + out.overshoot = rest; + } else { + out.shatter = rest; + } + return out; +} + [[nodiscard]] inline auto planUnglue( const voxel::Volume& volume, const voxel::Palette& palette, const voxel::MaterialLibrary& materials, const glm::vec3& start, @@ -331,7 +461,11 @@ inline auto planCrater( const voxel::Volume& volume, const voxel::Palette& palette, const voxel::MaterialLibrary& materials, const FractureRequest& request ) -> Crater { - Crater out {.start = request.point, .end = request.point}; + Crater out { + .start = request.point, + .end = request.point, + .seed = request.seed, + }; if (not std::isfinite(request.energy_density) || not(request.energy_density > 0.0f) || not(request.radius > 0.0f)) { return out; } @@ -342,7 +476,18 @@ inline auto planCrater( return out; } - std::vector candidates = fractureCandidates(volume, palette, materials, request); + out.pattern = hit->fracture_pattern; + out.profile = hit->fracture_profile.unpack(); + out.toughness = hit->toughness; + out.edge_noise = std::clamp(out.profile.edge_noise, 0.0f, k_max_edge_noise); + out.edge_noise_scale = std::max(out.profile.edge_noise_scale, 0.0f) * voxel::k_voxel_size; + out.edge_lobes = std::clamp(out.profile.edge_lobes, 0.0f, k_max_edge_noise); + + // as far as the crater can grow + const bool digs = glm::dot(request.direction, request.direction) > 0.0f && request.max_depth > 0.0f; + const glm::vec3 far = digs ? request.point + (request.direction * request.max_depth) : request.point; + out.reachable = reachableMaterial(volume, request.point, far, request.radius * (1.0f + std::max(request.max_overshoot, 0.0f))); + std::vector candidates = fractureCandidates(volume, palette, materials, request, out.reachable); const auto deeper = std::ranges::partition(candidates, [](const FractureCandidate& c) { return c.depth <= 0.0f; }); const std::span inside(candidates.begin(), deeper.begin()); const std::span beyond(deeper.begin(), deeper.end()); @@ -407,8 +552,13 @@ inline auto planCrater( ); left -= out.unglue.cost; + auto split = splitLeftoverEnergy(left, out.profile.debris_share, out.profile.fragment_share, out.pattern); + out.debris_energy = split.debris; + out.fragment_energy = split.fragment; + out.shatter_energy = split.shatter; const float toughness = paid > 0 ? paid_cost / (static_cast(paid) * k_voxel_volume) : hit->toughness; - out.radius += overshootRadius(left, toughness, request.radius, glm::length(out.end - out.start), request.max_overshoot); + out.radius += + overshootRadius(split.overshoot, toughness, request.radius, glm::length(out.end - out.start), request.max_overshoot); return out; } diff --git a/engine/src/toast/physics/fracture_pattern.hpp b/engine/src/toast/physics/fracture_pattern.hpp new file mode 100644 index 000000000..cd04d04a4 --- /dev/null +++ b/engine/src/toast/physics/fracture_pattern.hpp @@ -0,0 +1,47 @@ +/** + * @file fracture_pattern.h + * @author Xein + * @date 16 Oct 2026 + * @brief TODO: Brief description of the file's purpose + */ + +#pragma once +#include +#include +#include +#include + +namespace physics { + +enum class FracturePattern : uint8_t { + brittle, ///< small cells with thin cracks + granular, ///< crumbles into debris + fibrous, ///< splinters + ductile, ///< no shatter +}; + +inline constexpr std::array k_fracture_pattern_names {"brittle", "granular", "fibrous", "ductile"}; + +[[nodiscard]] +constexpr auto fracturePatternName(FracturePattern pattern) -> std::string_view { + auto index = static_cast(pattern); + return index < k_fracture_pattern_names.size() ? k_fracture_pattern_names[index] : std::string_view {}; +} + +[[nodiscard]] +constexpr auto fracturePatternFromName(std::string_view name) -> std::optional { + for (size_t i = 0; i < k_fracture_pattern_names.size(); ++i) { + if (k_fracture_pattern_names[i] == name) { + return static_cast(i); + } + } + return std::nullopt; +} + +/// @returns true if the left over energy generates a crack +[[nodiscard]] +constexpr auto shatters(FracturePattern pattern) -> bool { + return pattern != FracturePattern::ductile; +} + +} diff --git a/engine/src/toast/physics/fracture_profile.hpp b/engine/src/toast/physics/fracture_profile.hpp new file mode 100644 index 000000000..1b8de9bf9 --- /dev/null +++ b/engine/src/toast/physics/fracture_profile.hpp @@ -0,0 +1,107 @@ +/** + * @file fracture_profile.hpp + * @author Xein + * @date 18 Oct 2026 + * @brief How a material breaks: how the energy of a hit is spent and how coarse its cracks are + */ + +#pragma once + +#include "material_defaults.hpp" + +#include +#include + +namespace physics { + +struct FractureValues { + float debris_share = material_defaults::k_debris_share; + float fragment_share = material_defaults::k_fragment_share; + + float fragment_threshold = material_defaults::k_fragment_threshold; + uint32_t fragment_max_parts = material_defaults::k_fragment_max_parts; + + float shatter_threshold = material_defaults::k_shatter_threshold; + float shatter_cell_size = material_defaults::k_shatter_cell_size; + float shatter_crack_cost = material_defaults::k_shatter_crack_cost; + float shatter_release = material_defaults::k_shatter_release; + float shatter_stretch = material_defaults::k_shatter_stretch; + float shatter_crumble = material_defaults::k_shatter_crumble; + + float edge_noise = material_defaults::k_edge_noise; + float edge_lobes = material_defaults::k_edge_lobes; + float edge_noise_scale = material_defaults::k_edge_noise_scale; +}; + +namespace detail { + +[[nodiscard]] +constexpr auto quantize(float value, float steps_per_unit, float max_steps) -> float { + if (not(value > 0.0f)) { + return 0.0f; + } + const float steps = (value * steps_per_unit) + 0.5f; + return steps >= max_steps ? max_steps : static_cast(static_cast(steps)); +} + +} + +struct PackedFractureProfile { + uint16_t fragment_threshold = 0; + uint16_t shatter_threshold = 0; + uint8_t debris_share = 0; + uint8_t fragment_share = 0; + uint8_t edge_noise = 0; + uint8_t edge_lobes = 0; + uint8_t shatter_release = 0; + uint8_t shatter_crumble = 0; + uint8_t edge_noise_scale = 0; + uint8_t shatter_crack_cost = 0; + uint8_t shatter_stretch = 0; + uint8_t shatter_cell_size = 0; + uint8_t fragment_max_parts = 0; + + [[nodiscard]] + static constexpr auto pack(const FractureValues& v) -> PackedFractureProfile { + return PackedFractureProfile { + .fragment_threshold = static_cast(detail::quantize(v.fragment_threshold, 1000.0f, 65535.0f)), + .shatter_threshold = static_cast(detail::quantize(v.shatter_threshold, 1000.0f, 65535.0f)), + .debris_share = static_cast(detail::quantize(v.debris_share, 255.0f, 255.0f)), + .fragment_share = static_cast(detail::quantize(v.fragment_share, 255.0f, 255.0f)), + .edge_noise = static_cast(detail::quantize(v.edge_noise, 255.0f, 255.0f)), + .edge_lobes = static_cast(detail::quantize(v.edge_lobes, 255.0f, 255.0f)), + .shatter_release = static_cast(detail::quantize(v.shatter_release, 255.0f, 255.0f)), + .shatter_crumble = static_cast(detail::quantize(v.shatter_crumble, 255.0f, 255.0f)), + .edge_noise_scale = static_cast(detail::quantize(v.edge_noise_scale, 4.0f, 255.0f)), + .shatter_crack_cost = static_cast(detail::quantize(v.shatter_crack_cost, 64.0f, 255.0f)), + .shatter_stretch = static_cast(detail::quantize(v.shatter_stretch, 16.0f, 255.0f)), + .shatter_cell_size = static_cast(detail::quantize(v.shatter_cell_size, 100.0f, 255.0f)), + .fragment_max_parts = static_cast(std::min(v.fragment_max_parts, 255u)), + }; + } + + [[nodiscard]] + constexpr auto unpack() const -> FractureValues { + return FractureValues { + .debris_share = static_cast(debris_share) / 255.0f, + .fragment_share = static_cast(fragment_share) / 255.0f, + .fragment_threshold = static_cast(fragment_threshold) / 1000.0f, + .fragment_max_parts = fragment_max_parts, + .shatter_threshold = static_cast(shatter_threshold) / 1000.0f, + .shatter_cell_size = static_cast(shatter_cell_size) / 100.0f, + .shatter_crack_cost = static_cast(shatter_crack_cost) / 64.0f, + .shatter_release = static_cast(shatter_release) / 255.0f, + .shatter_stretch = static_cast(shatter_stretch) / 16.0f, + .shatter_crumble = static_cast(shatter_crumble) / 255.0f, + .edge_noise = static_cast(edge_noise) / 255.0f, + .edge_lobes = static_cast(edge_lobes) / 255.0f, + .edge_noise_scale = static_cast(edge_noise_scale) / 4.0f, + }; + } +}; + +namespace material_defaults { +inline constexpr PackedFractureProfile k_fracture_profile = PackedFractureProfile::pack(FractureValues {}); +} + +} diff --git a/engine/src/toast/physics/fracture_random.hpp b/engine/src/toast/physics/fracture_random.hpp new file mode 100644 index 000000000..9181c0c62 --- /dev/null +++ b/engine/src/toast/physics/fracture_random.hpp @@ -0,0 +1,123 @@ +/** + * @file fracture_random.hpp + * @author Xein + * @date 16 Oct 2026 + * @brief Deterministic randomness for destruction + */ + +#pragma once + +#include "body.hpp" +#include "shape.hpp" + +#include +#include +#include +#include + +namespace physics { + +enum class FractureStream : uint8_t { + edge_noise = 1, + shatter_points = 2, + granular = 3, + debris = 4, + edge_lobes = 6, + core_split = 7, +}; + +[[nodiscard]] +constexpr auto streamSeed(uint64_t seed, FractureStream stream) noexcept -> uint64_t { + toast::hashCombine(seed, static_cast(stream)); + return seed; +} + +[[nodiscard]] +constexpr auto unitFloat(uint64_t bits) noexcept -> float { + // this function takes the main 24 bits from a u64 number and converts it + // to a floating point number on the range [0, 1] + return static_cast(bits >> 40) * 0x1.0p-24f; +} + +inline auto sphereDirection(float u, float v) noexcept -> glm::vec3 { + float z = (u * 2.0f) - 1.0f; + float ring = std::sqrt(glm::max(1.0f - (z * z), 0.0f)); + float phi = v * 2.0f * std::numbers::pi_v; + return {ring * std::cos(phi), ring * std::sin(phi), z}; +} + +[[nodiscard]] +constexpr auto hashCell(uint64_t seed, glm::ivec3 cell) noexcept -> uint64_t { + toast::hashCombine(seed, static_cast(cell.x)); + toast::hashCombine(seed, static_cast(cell.y)); + toast::hashCombine(seed, static_cast(cell.z)); + return seed; +} + +[[nodiscard]] +inline auto fractureSeed(BodyID source, ShapeID shape, glm::vec3 local_point, uint64_t tick) noexcept -> uint64_t { + // calculates the seed of one hit + uint64_t seed = 0; + toast::hashCombine(seed, (static_cast(source.generation) << 32) | source.slot); + toast::hashCombine(seed, (static_cast(shape.generation) << 32) | source.slot); + seed = hashCell(seed, glm::ivec3(glm::floor(local_point / voxel::k_voxel_size))); + toast::hashCombine(seed, tick); + return seed != 0 ? seed : 1; +} + +[[nodiscard]] +constexpr auto hash01(uint64_t seed, glm::ivec3 cell) noexcept -> float { + return unitFloat(hashCell(seed, cell)); +} + +[[nodiscard]] +inline auto hashUnit3(uint64_t seed, uint64_t index) noexcept -> glm::vec3 { + toast::hashCombine(seed, index); + return sphereDirection(unitFloat(seed), unitFloat(seed << 24)); +} + +[[nodiscard]] +inline auto valueNoise3(uint64_t seed, glm::vec3 p) noexcept -> float { + // trilinear of the random + glm::vec3 base = glm::floor(p); + glm::ivec3 cell = {base}; + glm::vec3 t = p - base; + glm::vec3 w = t * t * (3.0f - (2.0f * t)); + +#define LARP(a, b, c) std::lerp(a, b, c) + + auto at = [&](int x, int y, int z) { return hash01(seed, cell + glm::ivec3 {x, y, z}); }; + float x00 = LARP(at(0, 0, 0), at(1, 0, 0), w.x); + float x01 = LARP(at(0, 1, 0), at(1, 1, 0), w.x); + float x10 = LARP(at(0, 0, 1), at(1, 0, 1), w.x); + float x11 = LARP(at(0, 1, 1), at(1, 1, 1), w.x); + return LARP(LARP(x00, x10, w.y), LARP(x01, x11, w.y), w.z); + +#undef LARP +} + +struct Rng { + uint64_t state = 0; + + explicit constexpr Rng(uint64_t seed) noexcept : state(seed) { } + + constexpr auto next() noexcept -> uint64_t { + state += toast::k_golden_gamma; + return toast::splitmix64(state); + } + + constexpr auto uniform() noexcept -> float { return unitFloat(next()); } + + constexpr auto range(float lo, float hi) noexcept -> float { return lo + ((hi - lo) * uniform()); } + + constexpr auto below(uint32_t count) noexcept -> uint32_t { return static_cast(((next() >> 32) * count) >> 32); } + + constexpr auto chance(float probability) noexcept -> bool { return uniform() < probability; } + + auto direction() noexcept -> glm::vec3 { + uint64_t bits = next(); + return sphereDirection(unitFloat(bits), unitFloat(bits << 24)); + } +}; + +} diff --git a/engine/src/toast/physics/material_defaults.hpp b/engine/src/toast/physics/material_defaults.hpp index 4c0fb766e..cbb6931cd 100644 --- a/engine/src/toast/physics/material_defaults.hpp +++ b/engine/src/toast/physics/material_defaults.hpp @@ -7,8 +7,11 @@ #pragma once +#include "fracture_pattern.hpp" #include "hardness.hpp" +#include + namespace physics::material_defaults { inline constexpr float k_restitution = 0.1f; @@ -23,8 +26,23 @@ inline constexpr float k_spin_friction = 0.005f; inline constexpr float k_density = 2000.0f; inline constexpr float k_toughness = 100.0f; inline constexpr Hardness k_hardness = Hardness::medium; +inline constexpr FracturePattern k_fracture_pattern = FracturePattern::brittle; /// weak bricks inline constexpr float k_structural_strength = 1.0e6f; +inline constexpr float k_debris_share = 0.2f; +inline constexpr float k_fragment_share = 0.5f; +inline constexpr float k_fragment_threshold = 0.3f; +inline constexpr uint32_t k_fragment_max_parts = 4; +inline constexpr float k_shatter_threshold = 0.1f; +inline constexpr float k_shatter_cell_size = 0.5f; +inline constexpr float k_shatter_crack_cost = 1.0f; +inline constexpr float k_shatter_release = 0.3f; +inline constexpr float k_shatter_stretch = 3.0f; +inline constexpr float k_shatter_crumble = 0.5f; +inline constexpr float k_edge_noise = 0.25f; +inline constexpr float k_edge_lobes = 0.35f; +inline constexpr float k_edge_noise_scale = 4.0f; + } diff --git a/engine/src/toast/physics/physics_settings.cpp b/engine/src/toast/physics/physics_settings.cpp index f63a06da7..9b119b85a 100644 --- a/engine/src/toast/physics/physics_settings.cpp +++ b/engine/src/toast/physics/physics_settings.cpp @@ -399,6 +399,48 @@ void registerPhysicsSettings() { {.label = "Max unglue voxels", .category = "Fracture", .min = 0.0, .max = 1000000.0, .step = 64.0}, &Tunables::fracture_max_unglue_voxels ); + flt("physics.fracture.debris_split_chance", + 0.35, + {.label = "Debris split chance", .category = "Shatter", .min = 0.0f, .max = 1.0f, .step = 0.05f}, + &Tunables::fracture_debris_split_chance); + uint_setting( + "physics.fracture.max_shatter_cells", + 128, + {.label = "Max shatter cells", .category = "Shatter", .min = 2.0, .max = 1024.0, .step = 8.0}, + &Tunables::fracture_max_shatter_cells + ); + flt("physics.fracture.max_shatter_distance", + 8.0, + {.label = "Max shatter distance", .category = "Shatter", .min = 0.1f, .max = 64.0f, .step = 0.5f}, + &Tunables::fracture_max_shatter_distance); + uint_setting( + "physics.debris.max_spawns_per_step", + 512, + {.label = "Max spawns per step", .category = "Debris", .min = 0.0, .max = 16384.0, .step = 16.0}, + &Tunables::debris_max_spawns_per_step + ); + flt("physics.debris.speed_scale", + 1.0, + {.label = "Speed scale", .category = "Debris", .min = 0.0f, .max = 8.0f, .step = 0.1f}, + &Tunables::debris_speed_scale); + flt("physics.debris.lifetime", + 60.0, + {.label = "Lifetime", .category = "Debris", .min = 0.5, .max = 600.0, .step = 0.5}, + &Tunables::debris_lifetime); + uint_setting( + "physics.debris.max_particles", + 16384, + {.label = "Max particles", .category = "Debris", .min = 256.0, .max = 262144.0, .step = 1024.0, .requires_restart = true}, + &Tunables::debris_max_particles + ); + flt("physics.debris.restitution", + 0.3, + {.label = "Restitution", .category = "Debris", .min = 0.0, .max = 1.0, .step = 0.05}, + &Tunables::debris_restitution); + flt("physics.debris.friction", + 0.6, + {.label = "Friction", .category = "Debris", .min = 0.0, .max = 1.0, .step = 0.05}, + &Tunables::debris_friction); uint_setting( "physics.jobs.min_bounds_per_job", diff --git a/engine/src/toast/physics/physics_settings.hpp b/engine/src/toast/physics/physics_settings.hpp index a9f9c6cd6..151cae763 100644 --- a/engine/src/toast/physics/physics_settings.hpp +++ b/engine/src/toast/physics/physics_settings.hpp @@ -43,6 +43,15 @@ struct Tunables { float fracture_max_overshoot = 2.0f; float fracture_bond_cost = 1.0f; uint32_t fracture_max_unglue_voxels = 4096; + float fracture_debris_split_chance = 0.35f; + uint32_t fracture_max_shatter_cells = 128; + float fracture_max_shatter_distance = 8.0f; + uint32_t debris_max_spawns_per_step = 512; + float debris_speed_scale = 1.0f; + float debris_lifetime = 60.0f; + uint32_t debris_max_particles = 16384; + float debris_restitution = 0.3f; + float debris_friction = 0.6f; float explosion_max_radius = 8.0f; float explosion_falloff = 2.0f; uint32_t impact_max_hardness = 1; diff --git a/engine/src/toast/physics/shatter.hpp b/engine/src/toast/physics/shatter.hpp new file mode 100644 index 000000000..451c83bb1 --- /dev/null +++ b/engine/src/toast/physics/shatter.hpp @@ -0,0 +1,361 @@ +/** + * @file shatter.hpp + * @author Xein + * @date 17 Oct 2026 + * @brief Voronoi cracks + */ + +#pragma once + +#include "fracture.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace physics { + +struct ShatterPlan { + std::vector crack; + float band_outer = 0.0f; + uint32_t cells = 0; + const char* stop = "ok"; // debug + float width = 0.0f; + float density = 0.0f; + size_t band_voxels = 0; +}; + +inline constexpr float k_min_crack_half_width = 0.5f * 1.7320508f; + +[[nodiscard]] +constexpr auto patternShatters(FracturePattern pattern) -> bool { + return pattern != FracturePattern::ductile; +} + +[[nodiscard]] +inline auto crackThickness() -> float { + return 2.0f * k_min_crack_half_width * voxel::k_voxel_size; +} + +[[nodiscard]] +inline auto minChunkVoxels(float crack_thickness) -> uint32_t { + const float side = (2.0f * crack_thickness) / voxel::k_voxel_size; + return static_cast(std::ceil(side * side * side)); +} + +[[nodiscard]] +inline auto craterVolume(const Crater& crater) -> float { + return capsuleVolume(crater.radius, glm::length(crater.end - crater.start)); +} + +[[nodiscard]] +inline auto planShatter( + const voxel::Volume& volume, const voxel::Palette& palette, const voxel::MaterialLibrary& materials, const Crater& crater, + Hardness max_hardness +) -> ShatterPlan { + ShatterPlan out; + out.band_outer = craterMaxRadius(crater); + + const FractureValues& values = crater.profile; + if (not crater.breaks || not(crater.shatter_energy > 0.0f) || not patternShatters(crater.pattern) || + not(crater.radius > 0.0f) || materials.materials.empty()) { + out.stop = "preconditions"; + return out; + } + + const float crater_volume = craterVolume(crater); + out.density = crater.shatter_energy / crater_volume; + if (out.density < values.shatter_threshold * crater.toughness) { + out.stop = "energy density below the threshold of the material"; + return out; + } + + const float threshold = crackThickness(); + const float min_width = 2.0f * threshold; + const float max_width = std::max(tunables().fracture_max_shatter_distance, min_width); + + // everything that could crack + struct BandVoxel { + glm::ivec3 voxel; + glm::vec3 center; + float outside; + }; + + std::vector band; + float toughness_sum = 0.0f; + const float reach = craterMaxRadius(crater) + max_width; + forEachSolidVoxel( + volume, + glm::min(crater.start, crater.end) - glm::vec3(reach), + glm::max(crater.start, crater.end) + glm::vec3(reach), + [&](glm::ivec3 v, glm::vec3 center, uint8_t index) { + const glm::vec3 axis_point = closestPointOnSegment(center, crater.start, crater.end); + const float outside = glm::length(center - axis_point) - craterRadiusAt(crater, center); + if (outside <= 0.0f || outside > max_width) { + return; + } + const voxel::PhysicalMaterial& material = materials.materials[voxel::resolveMaterialIndex(palette, materials, index)]; + if (not material.breakableBy(max_hardness) || not damageReaches(crater.reachable, volume, v, axis_point, center)) { + return; + } + band.push_back({.voxel = v, .center = center, .outside = outside}); + toughness_sum += material.toughness; + } + ); + if (band.empty()) { + out.stop = "no breakable material around the crater"; + return out; + } + + const float cell_size = std::max(values.shatter_cell_size, threshold); + const float mean_toughness = toughness_sum / static_cast(band.size()); + const float length = glm::length(crater.end - crater.start); + const float base_volume = capsuleVolume(crater.radius, length); + const float fill = std::min(1.0f, 3.0f * threshold / cell_size); + const auto band_volume_of = [&](float w) { return capsuleVolume(crater.radius + w, length) - base_volume; }; + const auto cost_of = [&](float w) { return mean_toughness * values.shatter_crack_cost * fill * band_volume_of(w); }; + float width = min_width; + if (cost_of(max_width) <= crater.shatter_energy) { + width = max_width; + } else if (cost_of(min_width) < crater.shatter_energy) { + float low = min_width; + float high = max_width; + for (int i = 0; i < 24; ++i) { + const float middle = 0.5f * (low + high); + (cost_of(middle) <= crater.shatter_energy ? low : high) = middle; + } + width = low; + } + out.width = width; + out.band_outer = craterMaxRadius(crater) + width; + std::erase_if(band, [width](const BandVoxel& b) { return b.outside > width; }); + out.band_voxels = band.size(); + if (band.size() < 2) { + out.stop = "no breakable material around the crater"; + return out; + } + + const float cells_wanted = std::clamp( + band_volume_of(width) / (cell_size * cell_size * cell_size), + 2.0f, + static_cast(std::max(tunables().fracture_max_shatter_cells, 2u)) + ); + const auto cell_count = static_cast(cells_wanted); + + // seeds + Rng rng {streamSeed(crater.seed, FractureStream::shatter_points)}; + const float min_gap = 2.0f * threshold; + std::vector seeds; + for (uint32_t attempt = 0; attempt < cell_count * 12 && seeds.size() < cell_count; ++attempt) { + const glm::vec3 point = band[rng.below(static_cast(band.size()))].center; + const bool crowded = std::ranges::any_of(seeds, [&](const glm::vec3& s) { return glm::distance(s, point) < min_gap; }); + if (not crowded) { + seeds.push_back(point); + } + } + if (seeds.size() < 2) { + out.stop = "fewer than 2 seeds"; + return out; + } + out.cells = static_cast(seeds.size()); + + // fibrous + const bool fibrous = crater.pattern == FracturePattern::fibrous; + const glm::uvec3 dims = volume.voxelDims(); + glm::vec3 grain {0.0f}; + if (dims.x >= dims.y && dims.x >= dims.z) { + grain[0] = 1.0f; + } else { + grain[(dims.y >= dims.z ? 1 : 2)] = 1.0f; + } + const auto metric = [&](const glm::vec3& offset) { + if (not fibrous) { + return glm::length(offset); + } + const float along = glm::dot(offset, grain); + const glm::vec3 lateral = offset - (grain * along); + const float stretched = along / std::max(values.shatter_stretch, 1.0f); + return std::sqrt(glm::dot(lateral, lateral) + (stretched * stretched)); + }; + + std::unordered_set in_band; + in_band.reserve(band.size()); + for (const BandVoxel& b : band) { + in_band.insert(voxelKey(b.voxel)); + } + + // solid material + const glm::ivec3 dims_i = glm::ivec3(dims); + const std::array k_faces { + glm::ivec3 { 1, 0, 0}, + glm::ivec3 {-1, 0, 0}, + glm::ivec3 { 0, 1, 0}, + glm::ivec3 { 0, -1, 0}, + glm::ivec3 { 0, 0, 1}, + glm::ivec3 { 0, 0, -1}, + }; + const auto touches_wall = [&](const glm::ivec3& v) { + for (const glm::ivec3& offset : k_faces) { + const glm::ivec3 n = v + offset; + if (glm::any(glm::lessThan(n, glm::ivec3(0))) || glm::any(glm::greaterThanEqual(n, dims_i)) || + volume.materialAt(n) == voxel::k_empty_palette_index || in_band.contains(voxelKey(n))) { + continue; + } + const glm::vec3 center = (glm::vec3 {n} + 0.5f) * voxel::k_voxel_size; + const float radius = craterRadiusAt(crater, center); + if (pointSegmentDistanceSquared(center, crater.start, crater.end) > radius * radius) { + return true; + } + } + return false; + }; + + struct Owner { + size_t nearest = 0; + size_t second = 0; + float gap = 0.0f; // d2 - d1 + }; + + std::vector owners(band.size()); + std::vector cell_voxels(seeds.size(), 0); + for (size_t b = 0; b < band.size(); ++b) { + float d1 = std::numeric_limits::max(); + float d2 = std::numeric_limits::max(); + Owner& owner = owners[b]; + for (size_t i = 0; i < seeds.size(); ++i) { + const float d = metric(band[b].center - seeds[i]); + if (d < d1) { + d2 = d1; + owner.second = owner.nearest; + d1 = d; + owner.nearest = i; + } else if (d < d2) { + d2 = d; + owner.second = i; + } + } + owner.gap = d2 - d1; + ++cell_voxels[owner.nearest]; + } + + const uint32_t min_cell_voxels = minChunkVoxels(threshold); + const float release = std::clamp(values.shatter_release, 0.0f, 1.0f); + std::vector released(seeds.size(), false); + std::vector eligible; + for (size_t i = 0; i < seeds.size(); ++i) { + if (cell_voxels[i] >= min_cell_voxels) { + eligible.push_back(i); + released[i] = rng.chance(release); + } + } + if (eligible.empty()) { + out.stop = "cells too small to come off"; + return out; + } + if (std::ranges::none_of(eligible, [&](size_t i) { return released[i]; })) { + released[eligible[rng.below(static_cast(eligible.size()))]] = true; + } + + const uint64_t crumble_seed = streamSeed(crater.seed, FractureStream::granular); + for (size_t b = 0; b < band.size(); ++b) { + const Owner& owner = owners[b]; + const bool mine = released[owner.nearest]; + const bool border = owner.gap < threshold && (mine || released[owner.second]); + bool crack = border; + crack = crack || (mine && touches_wall(band[b].voxel)); + crack = crack || + (mine && crater.pattern == FracturePattern::granular && hash01(crumble_seed, band[b].voxel) < values.shatter_crumble); + if (crack) { + out.crack.push_back(band[b].voxel); + } + } + return out; +} + +struct CoreVoxel { + glm::ivec3 voxel {0}; + glm::vec3 center {0.0f}; +}; + +struct CoreSplit { + std::vector crack; + uint32_t parts = 0; + float density = 0.0f; +}; + +[[nodiscard]] +inline auto planCoreSplit(std::span core, const Crater& crater) -> CoreSplit { + CoreSplit out; + const FractureValues& values = crater.profile; + if (not(crater.fragment_energy > 0.0f) || not(crater.radius > 0.0f)) { + return out; + } + out.density = crater.fragment_energy / craterVolume(crater); + const float part_density = std::max(values.fragment_threshold * crater.toughness, 1.0e-9f); + if (out.density < part_density) { + return out; + } + + Rng rng {streamSeed(crater.seed, FractureStream::core_split)}; + const float doublings = std::log2(out.density / part_density); + const float parts_wanted = + std::clamp(2.0f + std::floor(doublings), 2.0f, static_cast(std::max(values.fragment_max_parts, 2u))); + + const float threshold = crackThickness(); + const auto room = static_cast(core.size() / minChunkVoxels(threshold)); + const uint32_t parts = std::min(static_cast(parts_wanted), room); + if (parts < 2) { + return out; + } + + std::vector seeds {core[rng.below(static_cast(core.size()))].center}; + while (seeds.size() < parts) { + glm::vec3 best = seeds.front(); + float best_gap = -1.0f; + for (int attempt = 0; attempt < 12; ++attempt) { + const glm::vec3 point = core[rng.below(static_cast(core.size()))].center; + float gap = std::numeric_limits::max(); + for (const glm::vec3& s : seeds) { + gap = std::min(gap, glm::distance(s, point)); + } + if (gap > best_gap) { + best_gap = gap; + best = point; + } + } + if (best_gap < 2.0f * threshold) { + break; // too small + } + seeds.push_back(best); + } + if (seeds.size() < 2) { + return out; + } + out.parts = static_cast(seeds.size()); + + for (const CoreVoxel& c : core) { + float d1 = std::numeric_limits::max(); + float d2 = std::numeric_limits::max(); + for (const glm::vec3& seed_point : seeds) { + const float d = glm::distance(c.center, seed_point); + if (d < d1) { + d2 = d1; + d1 = d; + } else if (d < d2) { + d2 = d; + } + } + if (d2 - d1 < threshold) { + out.crack.push_back(c.voxel); + } + } + return out; +} + +} diff --git a/engine/src/toast/physics/simulator.cpp b/engine/src/toast/physics/simulator.cpp index 75e84871f..130aca271 100644 --- a/engine/src/toast/physics/simulator.cpp +++ b/engine/src/toast/physics/simulator.cpp @@ -13,6 +13,7 @@ #include "nodes/sphere_collider.hpp" #include "nodes/trigger.hpp" #include "physics_settings.hpp" +#include "shatter.hpp" #include "toast/physics/body.hpp" #include "voxel_data_lock.hpp" #include "voxel_smash.hpp" @@ -280,6 +281,7 @@ void Simulator::resetWind() { Simulator::~Simulator() { TOAST_INFO("Physics", "Simulator destroyed"); + clearDebris(); instance = nullptr; // Finish all requests before destroying Requests& all = requests(); @@ -656,6 +658,7 @@ void Simulator::tick() { PhaseScope step_phase {*this, SimulationPhase::idle, SimulationPhase::mutation}; runPendingRequests(); m_profile = {}; + ++m_tick_index; const auto tick_start = std::chrono::steady_clock::now(); const auto elapsed_ms = [](std::chrono::steady_clock::time_point from, std::chrono::steady_clock::time_point to) { @@ -663,6 +666,7 @@ void Simulator::tick() { }; const float dt = static_cast(Accumulator::fixedDelta()); + reapFragments(); syncEnabledState(); applyDamageCommands(); @@ -798,6 +802,137 @@ void Simulator::clearFragmentFromSource( rebuildMassProperties(shape->owner); } +void Simulator::emitDebris( + ShapeID shape_id, std::span voxels, const DebrisBlast* blast, const FragmentPush* push, uint64_t seed +) { + ZoneScopedN("physics::EmitDebris"); + + const size_t cap = tunables().debris_max_spawns_per_step; + if (voxels.empty() || m_debris_spawns.size() >= cap) { + return; + } + const Shape* shape = tryGetShape(shape_id); + if (shape == nullptr || shape->type != ShapeType::voxel) { + return; + } + const VoxelShapeData* data = tryGetVoxelData(shape->voxel.data); + const Body* body = tryGetBody(shape->owner); + if (data == nullptr || body == nullptr) { + return; + } + + Rng rng {streamSeed(seed, FractureStream::debris)}; + const glm::ivec3 jitter { + static_cast(rng.below(2)), static_cast(rng.below(2)), static_cast(rng.below(2)) + }; + std::vector clusters = clusterDebris(voxels, jitter, rng, tunables().fracture_debris_split_chance); + + // past the cap a random subset is kept and the rest is deleted + const size_t room = cap - m_debris_spawns.size(); + if (clusters.size() > room) { + for (size_t i = 0; i < room; ++i) { + std::swap(clusters[i], clusters[i + rng.below(static_cast(clusters.size() - i))]); + } + clusters.resize(room); + } + if (clusters.empty()) { + return; + } + + const glm::quat lattice_rotation = body->rotation * shape->voxel.local_rotation; + const glm::vec3 world_com = body->worldCenterOfMass(); + const float energy_each = + blast != nullptr ? blast->energy * k_joules_per_kilojoule / static_cast(clusters.size()) : 0.0f; + const float speed_scale = tunables().debris_speed_scale; + const glm::vec3 push_direction = push != nullptr ? push->direction : glm::vec3 {0.0f}; + const float push_length = glm::length(push_direction); + + m_debris_spawns.reserve(m_debris_spawns.size() + clusters.size()); + for (const DebrisCluster& cluster : clusters) { + // the block is 2 voxels wide + const glm::vec3 local = (glm::vec3(cluster.origin) + 1.0f) * voxel::k_voxel_size; + const glm::vec3 position = + body->position + (body->rotation * (shape->voxel.local_center + (shape->voxel.local_rotation * local))); + + const uint32_t material_index = voxel::resolveMaterialIndex(data->palette, data->materials, cluster.material); + const voxel::PaletteEntry& look = data->palette.entries[cluster.material]; + const float mass = static_cast(std::popcount(cluster.mask)) * + static_cast(data->materials.materials[material_index].density) * k_voxel_volume; + + // starts with the speed of the point it broke off from + glm::vec3 velocity = body->linear_velocity + glm::cross(body->angular_velocity, position - world_com); + + if (blast != nullptr) { + // thrown outward from the crater axis inside a cone + const glm::vec3 outward = local - closestPointOnSegment(local, blast->start, blast->end); + const float outward_length = glm::length(outward); + const glm::vec3 axis_local = outward_length > 1.0e-4f ? outward / outward_length : rng.direction(); + const glm::vec3 axis = body->rotation * (shape->voxel.local_rotation * axis_local); + velocity += debrisConeDirection(axis, rng, k_debris_cone_spread) * debrisSpeed(energy_each, mass, speed_scale); + } + if (push != nullptr && push_length > 1.0e-6f && mass > 0.0f) { + const float speed = std::min({push->speed, push->max_impulse / mass, k_max_debris_speed}); + if (std::isfinite(speed) && speed > 0.0f) { + velocity += (push_direction / push_length) * speed; + } + } + + m_debris_spawns.push_back( + DebrisSpawn { + .position = position, + .rotation = lattice_rotation, + .velocity = velocity, + .angular_velocity = rng.direction() * rng.range(0.0f, k_debris_max_spin), + .mask = cluster.mask, + .material = cluster.material, + .albedo_roughness = static_cast(look.albedo_r) | (static_cast(look.albedo_g) << 8u) | + (static_cast(look.albedo_b) << 16u) | (static_cast(look.roughness) << 24u), + .metallic = look.metallic, + .reflectivity = look.reflectivity, + } + ); + } +} + +void Simulator::emitComponentDebris(ShapeID shape_id, const DetachedComponent& component, const FragmentPush* push) { + const Shape* shape = tryGetShape(shape_id); + const VoxelShapeData* data = shape != nullptr && shape->type == ShapeType::voxel ? tryGetVoxelData(shape->voxel.data) : nullptr; + if (data == nullptr || data->volume == nullptr) { + return; + } + + std::vector voxels; + voxels.reserve(component.voxel_count); + for (const voxel::BrickPiece& piece : component.pieces) { + const voxel::BrickOccupancy* occupancy = data->volume->occupancyPointer(piece.brick); + if (occupancy == nullptr) { + continue; + } + const voxel::BrickOccupancy mask = piece.voxels & *occupancy; + const glm::ivec3 base = piece.brick * static_cast(voxel::k_brick_dim); + for (uint32_t z = 0; z < voxel::k_brick_dim; ++z) { + uint64_t word = mask[z]; + while (word != 0ull) { + const auto bit = static_cast(std::countr_zero(word)); + word &= word - 1ull; + const glm::ivec3 v = + base + glm::ivec3(static_cast(bit & 7u), static_cast(bit >> 3u), static_cast(z)); + voxels.push_back({.voxel = v, .material = data->volume->materialAt(v)}); + } + } + } + if (voxels.empty()) { + return; + } + + const uint64_t seed = fractureSeed(shape->owner, shape_id, glm::vec3(voxels.front().voxel) * voxel::k_voxel_size, m_tick_index); + emitDebris(shape_id, voxels, nullptr, push, seed); +} + +auto Simulator::takeDebrisSpawns() -> std::vector { + return std::exchange(m_debris_spawns, {}); +} + auto Simulator::voxelNodeFor(ShapeID shape) -> toast::VoxelNode* { const auto binding = std::ranges::find(m_voxel_bindings, shape, &VoxelNodeBinding::shape); return binding != m_voxel_bindings.end() && binding->node.exists() ? &*binding->node : nullptr; @@ -1028,6 +1163,7 @@ void Simulator::queuePendingFragments(std::span result components_to_spawn.reserve(data->detached_components.size()); for (const DetachedComponent& component : data->detached_components) { if (component.voxel_count < tunables().min_fragment_voxels) { + emitComponentDebris(r.shape, component, push.has_value() ? &*push : nullptr); clearFragmentFromSource(r.shape, *data, *data->volume, component); continue; } @@ -1395,7 +1531,24 @@ auto Simulator::spawnFragmentBody(ShapeID source_shape_id, const DetachedCompone } if (const Body* chunk = push != nullptr ? tryGetBody(frag_body) : nullptr) { - pushBody(frag_body, push->point.value_or(chunk->worldCenterOfMass()), push->direction, push->speed, push->max_impulse); + constexpr uint32_t k_push_min_voxels = 2; + const float mass_share = + static_cast(std::min(component.voxel_count, k_push_min_voxels)) / static_cast(k_push_min_voxels); + + const glm::vec3 linear_before = chunk->linear_velocity; + const glm::vec3 angular_before = chunk->angular_velocity; + pushBody( + frag_body, push->point.value_or(chunk->worldCenterOfMass()), push->direction, push->speed, push->max_impulse * mass_share + ); + + if (Body* pushed = tryGetBody(frag_body); pushed != nullptr && pushed->extent_radius > 1.0e-3f) { + const glm::vec3 spin_gain = pushed->angular_velocity - angular_before; + const float spin = glm::length(spin_gain); + const float spin_limit = glm::length(pushed->linear_velocity - linear_before) / pushed->extent_radius; + if (spin > spin_limit) { + pushed->angular_velocity = angular_before + (spin_gain * (spin_limit / spin)); + } + } } return true; @@ -4015,10 +4168,13 @@ void Simulator::applyDamageCommand(const DamageCommand& c) { glm::vec3 removed_sum {0.0f}; Crater crater; SmashPieces unglued; + std::vector debris_voxels; { std::scoped_lock voxel_lock {voxelDataMutex()}; + const uint64_t seed = c.seed != 0 ? c.seed : fractureSeed(c.source, c.shape, local_center, m_tick_index); + crater = planCrater( volume, data->palette, @@ -4028,7 +4184,8 @@ void Simulator::applyDamageCommand(const DamageCommand& c) { .direction = local_direction, .radius = c.fracture_radius, .energy_density = c.energy_density, - .max_hardness = c.max_hardness + .max_hardness = c.max_hardness, + .seed = seed, } ); if (not crater.breaks) { @@ -4036,15 +4193,12 @@ void Simulator::applyDamageCommand(const DamageCommand& c) { } const float shell = glm::max(c.shell_voxels, 0.0f) * voxel::k_voxel_size; - const float inner_radius = shell > 0.0f ? glm::max(crater.radius - shell, 0.0f) : 0.0f; - const float outer_squared = crater.radius * crater.radius; - const float inner_squared = inner_radius * inner_radius; + const float reach = craterMaxRadius(crater); + const float reach_squared = reach * reach; const glm::ivec3 voxel_dims = glm::ivec3(volume.voxelDims()); - glm::ivec3 first = - glm::ivec3(glm::floor((glm::min(crater.start, crater.end) - glm::vec3(crater.radius)) / voxel::k_voxel_size)); - glm::ivec3 last = - glm::ivec3(glm::ceil((glm::max(crater.start, crater.end) + glm::vec3(crater.radius)) / voxel::k_voxel_size)) - 1; + glm::ivec3 first = glm::ivec3(glm::floor((glm::min(crater.start, crater.end) - glm::vec3(reach)) / voxel::k_voxel_size)); + glm::ivec3 last = glm::ivec3(glm::ceil((glm::max(crater.start, crater.end) + glm::vec3(reach)) / voxel::k_voxel_size)) - 1; first = glm::clamp(first, glm::ivec3(0), voxel_dims - 1); last = glm::clamp(last, glm::ivec3(0), voxel_dims - 1); if (first.x > last.x || first.y > last.y || first.z > last.z) { @@ -4055,6 +4209,56 @@ void Simulator::applyDamageCommand(const DamageCommand& c) { const glm::ivec3 first_brick = first / brick_dim; const glm::ivec3 last_brick = last / brick_dim; + struct Doomed { + // we 100% need to remove this voxels + glm::ivec3 voxel; + glm::ivec3 brick; + uint16_t density; + uint8_t material; + }; + + std::vector doomed; + std::vector core; + + // cracks in the band around the crater + const ShatterPlan shatter = planShatter(volume, data->palette, data->materials, crater, c.max_hardness); + TOAST_INFO( + "Physics", + "Shatter: pattern {} shatter_energy {:.3f} kJ density {:.2f} kJ/m3 radius {:.3f} m band_voxels {} width {:.3f} m cells " + "{} cracks {} | stop: " + "{}", + fracturePatternName(crater.pattern), + crater.shatter_energy, + shatter.density, + crater.radius, + shatter.band_voxels, + shatter.width, + shatter.cells, + shatter.crack.size(), + shatter.stop + ); + + const float seal_floor = glm::max(craterMinRadius(crater) - voxel::k_voxel_size, 0.0f); + const float seal_floor_squared = seal_floor * seal_floor; + const std::array k_face_offsets { + glm::ivec3 { 1, 0, 0}, + glm::ivec3 {-1, 0, 0}, + glm::ivec3 { 0, 1, 0}, + glm::ivec3 { 0, -1, 0}, + glm::ivec3 { 0, 0, 1}, + glm::ivec3 { 0, 0, -1}, + }; + const auto touches_outside = [&](const glm::ivec3& v) { + for (const glm::ivec3& offset : k_face_offsets) { + const glm::vec3 center = (glm::vec3 {v + offset} + 0.5f) * voxel::k_voxel_size; + const float radius = craterRadiusAt(crater, center); + if (pointSegmentDistanceSquared(center, crater.start, crater.end) > radius * radius) { + return true; + } + } + return false; + }; + for (int32_t bz = first_brick.z; bz <= last_brick.z; ++bz) { for (int32_t by = first_brick.y; by <= last_brick.y; ++by) { for (int32_t bx = first_brick.x; bx <= last_brick.x; ++bx) { @@ -4063,7 +4267,6 @@ void Simulator::applyDamageCommand(const DamageCommand& c) { continue; } - bool brick_dirty = false; const glm::ivec3 brick_first = glm::max(first, brick * brick_dim); const glm::ivec3 brick_last = glm::min(last, brick * brick_dim + (brick_dim - 1)); for (int32_t z = brick_first.z; z <= brick_last.z; ++z) { @@ -4078,11 +4281,19 @@ void Simulator::applyDamageCommand(const DamageCommand& c) { const glm::vec3 voxel_center = (glm::vec3 {v} + 0.5f) * voxel::k_voxel_size; const float distance_squared = pointSegmentDistanceSquared(voxel_center, crater.start, crater.end); - if (distance_squared > outer_squared || distance_squared < inner_squared) { + // cheap reject before paying for 8 noise taps + if (distance_squared > reach_squared) { continue; } - if (not reachesThroughMaterial( - volume, closestPointOnSegment(voxel_center, crater.start, crater.end), voxel_center + const float outer = craterRadiusAt(crater, voxel_center); + const float inner = shell > 0.0f ? glm::max(outer - shell, 0.0f) : 0.0f; + if (distance_squared > outer * outer) { + continue; + } + const bool in_core = + distance_squared < inner * inner && not(distance_squared >= seal_floor_squared && touches_outside(v)); + if (not damageReaches( + crater.reachable, volume, v, closestPointOnSegment(voxel_center, crater.start, crater.end), voxel_center )) { continue; } @@ -4093,26 +4304,70 @@ void Simulator::applyDamageCommand(const DamageCommand& c) { continue; } - voxel::Volume::VoxelWrite write = volume.setVoxel(v, voxel::k_empty_palette_index); - if (write.changed) { - data->moments.remove(v.x, v.y, v.z, material.density); - data->solid_voxel_count -= data->solid_voxel_count > 0 ? 1u : 0u; - brick_dirty = true; - data->connectivity_dirty = true; - ++removed; - removed_sum += glm::vec3(v); + if (in_core) { + // stays in the wall for now + core.push_back({.voxel = v, .center = voxel_center}); + continue; } + doomed.push_back({.voxel = v, .brick = brick, .density = material.density, .material = palette_index}); } } } - - if (brick_dirty) { - dirty_bricks.push_back(brick); - } } } } + // crater voxels and crack voxels go through the same write path + std::unordered_set dirty_seen; + const auto carve = [&](const glm::ivec3& v, uint8_t palette_index, uint16_t density) { + const voxel::Volume::VoxelWrite write = volume.setVoxel(v, voxel::k_empty_palette_index); + if (not write.changed) { + return; + } + data->moments.remove(v.x, v.y, v.z, density); + data->solid_voxel_count -= data->solid_voxel_count > 0 ? 1u : 0u; + data->connectivity_dirty = true; + ++removed; + removed_sum += glm::vec3(v); + const glm::ivec3 brick = v / brick_dim; + if (dirty_seen.insert(voxelKey(brick)).second) { + dirty_bricks.push_back(brick); + } + debris_voxels.push_back({.voxel = v, .material = palette_index}); + }; + for (const Doomed& d : doomed) { + carve(d.voxel, d.material, d.density); + } + + // fragmenting + const CoreSplit core_split = planCoreSplit(core, crater); + TOAST_INFO( + "Physics", + "Fragment: energy {:.3f} kJ density {:.2f} kJ/m3 core_voxels {} parts {} cracks {}", + crater.fragment_energy, + core_split.density, + core.size(), + core_split.parts, + core_split.crack.size() + ); + for (const glm::ivec3& v : core_split.crack) { + const uint8_t palette_index = volume.materialAt(v); + if (palette_index == voxel::k_empty_palette_index) { + continue; + } + const uint32_t material_index = voxel::resolveMaterialIndex(data->palette, data->materials, palette_index); + carve(v, palette_index, data->materials.materials[material_index].density); + } + + for (const glm::ivec3& v : shatter.crack) { + const uint8_t palette_index = volume.materialAt(v); + if (palette_index == voxel::k_empty_palette_index) { + continue; + } + const uint32_t material_index = voxel::resolveMaterialIndex(data->palette, data->materials, palette_index); + carve(v, palette_index, data->materials.materials[material_index].density); + } + if (dirty_bricks.empty()) { return; } @@ -4138,6 +4393,11 @@ void Simulator::applyDamageCommand(const DamageCommand& c) { } } + if (not debris_voxels.empty()) { + const DebrisBlast blast {.start = crater.start, .end = crater.end, .energy = crater.debris_energy}; + emitDebris(c.shape, debris_voxels, &blast, c.push.has_value() ? &*c.push : nullptr, crater.seed); + } + ZoneValue(static_cast(dirty_bricks.size())); m_profile.dirty_bricks += dirty_bricks.size(); m_profile.surface_bricks_repaired += dirty_bricks.size(); @@ -4175,7 +4435,7 @@ void Simulator::applyDamageCommand(const DamageCommand& c) { unlockSleep(shape->owner); // crater in world space - const float reach = crater.radius + glm::length(crater.end - crater.start); + const float reach = craterMaxRadius(crater) + glm::length(crater.end - crater.start); const AABB crater_bounds { .min = c.world_center - glm::vec3(reach), .max = c.world_center + glm::vec3(reach), @@ -4597,6 +4857,11 @@ auto Simulator::breakPieces( } } + if (piece.voxel_count < tunables().min_fragment_voxels) { + const std::optional debris_push = push_for(piece); + emitComponentDebris(shape_id, piece, debris_push.has_value() ? &*debris_push : nullptr); + } + shape = tryGetShape(shape_id); data = shape != nullptr ? tryGetVoxelData(shape->voxel.data) : nullptr; if (data != nullptr && data->volume != nullptr) { diff --git a/engine/src/toast/physics/simulator.hpp b/engine/src/toast/physics/simulator.hpp index 459c4246d..228b8f9e7 100644 --- a/engine/src/toast/physics/simulator.hpp +++ b/engine/src/toast/physics/simulator.hpp @@ -11,6 +11,7 @@ #include "broad_phase.hpp" #include "constraint.hpp" #include "damage_command.hpp" +#include "debris.hpp" #include "fragment_extraction.hpp" #include "manifold.hpp" #include "narrow_phase.hpp" @@ -314,6 +315,9 @@ class TOAST_API Simulator { /// Raises the velocity of a dynamic body along direction void pushBody(BodyID id, const glm::vec3& point, const glm::vec3& direction, float speed, float max_impulse); + [[nodiscard]] + auto takeDebrisSpawns() -> std::vector; + /// Simulator running the current step, null outside a play session [[nodiscard]] static auto current() -> Simulator*; @@ -586,6 +590,13 @@ class TOAST_API Simulator { ShapeID shape_id, VoxelShapeData& data, voxel::Volume& source, const DetachedComponent& component, const RemovedVoxels* already_removed = nullptr ); + + void emitDebris( + ShapeID shape_id, std::span voxels, const DebrisBlast* blast, const FragmentPush* push, uint64_t seed + ); + + void emitComponentDebris(ShapeID shape_id, const DetachedComponent& component, const FragmentPush* push); + [[nodiscard]] auto spawnFragmentBody(ShapeID source_shape_id, const DetachedComponent& component, const FragmentPush* push = nullptr) -> bool; auto smashShape(ShapeID shape_id, const SmashVolume& volume, const CapsuleSmash& smash, bool chunks) -> uint32_t; @@ -670,21 +681,21 @@ class TOAST_API Simulator { NarrowPhase m_narrow_phase; std::vector m_manifolds; std::vector m_cached_manifolds; - /// mutable so const query methods (overlapCapsule/sweepCapsule) can still count how often they run mutable PhysicsStepProfile m_profile; PhysicsStepProfile m_published_profile; std::vector m_voxel_shapes; std::deque m_free_voxel_shape_slots; std::vector m_voxel_render_records; + std::vector m_debris_spawns; std::mutex m_damage_mutex; std::vector m_damage_commands; std::vector m_debug_dirty_bricks; uint64_t m_next_fragment_sequence = 1; + uint64_t m_tick_index = 0; std::vector m_fragments; - /// body slot -> index in m_fragments rebuilt whenever an erase shifts indices std::unordered_map m_fragment_index; std::vector m_pending_fragments; std::vector m_doomed_fragments; diff --git a/engine/src/toast/renderer/async_compute_task.cpp b/engine/src/toast/renderer/async_compute_task.cpp index a6b37db29..2efa2b0ba 100644 --- a/engine/src/toast/renderer/async_compute_task.cpp +++ b/engine/src/toast/renderer/async_compute_task.cpp @@ -21,7 +21,10 @@ namespace renderer { namespace { constexpr vk::BufferUsageFlags k_task_buffer_usage = - vk::BufferUsageFlagBits::eStorageBuffer | vk::BufferUsageFlagBits::eTransferSrc | vk::BufferUsageFlagBits::eTransferDst; + vk::BufferUsageFlagBits::eStorageBuffer | vk::BufferUsageFlagBits::eTransferSrc | vk::BufferUsageFlagBits::eTransferDst | + vk::BufferUsageFlagBits::eIndirectBuffer; + +constexpr vk::DeviceSize k_placeholder_bytes = 16; } @@ -42,13 +45,29 @@ void AsyncComputeTask::StepContext::dispatchRaw( const vk::PipelineLayout layout = *m_task.m_layout.getPipelineLayout(); m_cmd.bindPipeline(vk::PipelineBindPoint::eCompute, m_task.m_pipelines[entry]->getPipeline()); - m_cmd.bindDescriptorSets(vk::PipelineBindPoint::eCompute, layout, 0, {*m_task.m_slots[m_slot].set}, {}); + std::vector sets; + sets.reserve(m_task.m_slots[m_slot].sets.size()); + for (const vk::raii::DescriptorSet& set : m_task.m_slots[m_slot].sets) { + sets.push_back(*set); + } + if (!sets.empty()) { + m_cmd.bindDescriptorSets(vk::PipelineBindPoint::eCompute, layout, 0, sets, {}); + } if (push != nullptr && push_size > 0) { m_cmd.pushConstants(layout, vk::ShaderStageFlagBits::eAll, 0, push_size, push); } m_cmd.dispatch(groups[0], groups[1], groups[2]); } +auto AsyncComputeTask::StepContext::hostData(BufferId id) const -> std::span { + if (id >= m_task.m_buffers.size() || !m_task.m_buffers[id].host) { + return {}; + } + const Buffer& buffer = m_task.m_buffers[id]; + auto* mapped = static_cast(buffer.copies[m_slot].getAllocation().getInfo().pMappedData); + return mapped != nullptr ? std::span(mapped, buffer.size) : std::span {}; +} + auto AsyncComputeTask::FrameView::buffer(BufferId id) const -> vk::Buffer { if (m_task == nullptr || id >= m_task->m_buffers.size() || !m_task->m_buffers[id].ring) { return nullptr; @@ -134,10 +153,37 @@ auto AsyncComputeTask::addPersistentBuffer(vk::DeviceSize size) -> BufferId { return id; } +auto AsyncComputeTask::addHostBuffer(vk::DeviceSize size) -> BufferId { + const vk::BufferCreateInfo buffer_ci({}, size, vk::BufferUsageFlagBits::eStorageBuffer); + vma::AllocationCreateInfo host_alloc {}; + host_alloc.usage = vma::MemoryUsage::eAutoPreferHost; + host_alloc.flags = vma::AllocationCreateFlagBits::eMapped | vma::AllocationCreateFlagBits::eHostAccessSequentialWrite; + + const auto id = static_cast(m_buffers.size()); + Buffer& buffer = m_buffers.emplace_back(); + buffer.host = true; + buffer.size = size; + for (uint32_t slot = 0; slot < k_slot_count; ++slot) { + buffer.copies.push_back(m_core->getAllocator().createBuffer(buffer_ci, host_alloc)); + setDebugName(*m_core, *buffer.copies.back(), std::format("{} Host{}[{}]", m_config.debug_name, id, slot)); + } + return id; +} + void AsyncComputeTask::bind(uint32_t binding, BufferId id, Copy copy) { m_bindings.push_back({.binding = binding, .id = id, .copy = copy}); } +void AsyncComputeTask::bindExternal(uint32_t set, uint32_t binding, std::function source) { + m_externals.push_back({.set = set, .binding = binding, .source = std::move(source)}); +} + +void AsyncComputeTask::setAcquireUsage(BufferId id, sync::Usage usage) { + if (id < m_buffers.size()) { + m_buffers[id].acquire_usage = usage; + } +} + void AsyncComputeTask::createDescriptors() { if (*m_pool || !m_ready) { return; @@ -148,37 +194,69 @@ void AsyncComputeTask::createDescriptors() { } const auto& device = m_core->getDevice(); + const auto set_count = static_cast(layouts.size()); + const auto descriptor_count = static_cast(m_bindings.size() + m_externals.size()); // Own pool since the renderer pool belongs to the render thread - const std::array pool_sizes {vk::DescriptorPoolSize( - vk::DescriptorType::eStorageBuffer, std::max(1, static_cast(m_bindings.size())) * k_slot_count - )}; + const std::array pool_sizes { + vk::DescriptorPoolSize(vk::DescriptorType::eStorageBuffer, std::max(1, descriptor_count) * k_slot_count) + }; m_pool = vk::raii::DescriptorPool( - device, vk::DescriptorPoolCreateInfo(vk::DescriptorPoolCreateFlagBits::eFreeDescriptorSet, k_slot_count, pool_sizes) + device, + vk::DescriptorPoolCreateInfo(vk::DescriptorPoolCreateFlagBits::eFreeDescriptorSet, k_slot_count * set_count, pool_sizes) ); - const vk::DescriptorSetLayout set_layout = *layouts[0]; + std::vector set_layouts; + set_layouts.reserve(set_count); + for (const auto& layout : layouts) { + set_layouts.push_back(*layout); + } for (Slot& slot : m_slots) { - auto sets = device.allocateDescriptorSets(vk::DescriptorSetAllocateInfo(*m_pool, 1, &set_layout)); - slot.set = std::move(sets[0]); + slot.sets = device.allocateDescriptorSets(vk::DescriptorSetAllocateInfo(*m_pool, set_layouts)); + } + + if (!m_externals.empty()) { + const vk::BufferCreateInfo placeholder_ci({}, k_placeholder_bytes, vk::BufferUsageFlagBits::eStorageBuffer); + vma::AllocationCreateInfo placeholder_alloc {}; + placeholder_alloc.usage = vma::MemoryUsage::eAutoPreferDevice; + m_placeholder = m_core->getAllocator().createBuffer(placeholder_ci, placeholder_alloc); + setDebugName(*m_core, *m_placeholder, std::format("{} Placeholder", m_config.debug_name)); } } void AsyncComputeTask::writeDescriptors(uint32_t slot) { // Before the first step every ring copy holds the upload so any other slot works as previous const uint32_t previous = m_last_written >= 0 ? static_cast(m_last_written) : (slot + 1) % k_slot_count; + const std::vector& sets = m_slots[slot].sets; + if (sets.empty()) { + return; + } std::vector infos; - infos.reserve(m_bindings.size()); + infos.reserve(m_bindings.size() + m_externals.size()); std::vector writes; - writes.reserve(m_bindings.size()); + writes.reserve(m_bindings.size() + m_externals.size()); for (const Binding& binding : m_bindings) { const Buffer& buffer = m_buffers[binding.id]; uint32_t copy = 0; if (buffer.ring) { copy = binding.copy == Copy::current ? slot : previous; + } else if (buffer.host) { + copy = slot; } infos.emplace_back(*buffer.copies[copy], 0, buffer.size); - writes.emplace_back(*m_slots[slot].set, binding.binding, 0, 1, vk::DescriptorType::eStorageBuffer, nullptr, &infos.back()); + writes.emplace_back(*sets[0], binding.binding, 0, 1, vk::DescriptorType::eStorageBuffer, nullptr, &infos.back()); + } + for (const ExternalBinding& external : m_externals) { + if (external.set >= sets.size()) { + continue; + } + const vk::Buffer source = external.source ? external.source() : vk::Buffer {}; + if (source) { + infos.emplace_back(source, 0, VK_WHOLE_SIZE); + } else { + infos.emplace_back(*m_placeholder, 0, k_placeholder_bytes); + } + writes.emplace_back(*sets[external.set], external.binding, 0, 1, vk::DescriptorType::eStorageBuffer, nullptr, &infos.back()); } // The set of a claimed slot is not bound by any pending job so rewriting it is safe m_core->getDevice().updateDescriptorSets(writes, {}); @@ -190,6 +268,10 @@ void AsyncComputeTask::upload(BufferId id, std::span data) { return; } Buffer& buffer = m_buffers[id]; + if (buffer.host) { + TOAST_ERROR("Render", "{} host buffers are written through StepContext::hostData", m_config.debug_name); + return; + } if (buffer.ring && m_step_counter > 0) { TOAST_ERROR("Render", "{} ring buffer upload after the first step would race frames reading it", m_config.debug_name); return; @@ -221,14 +303,17 @@ void AsyncComputeTask::upload(BufferId id, std::span data) { auto unused = compute.wait(ticket); } -void AsyncComputeTask::step(const std::function& record, std::string_view debug_name) { +auto AsyncComputeTask::step( + const std::function& record, std::string_view debug_name, std::shared_ptr keep_alive +) -> uint64_t { ZoneScoped; if (!m_ready) { - return; + return 0; } createDescriptors(); const uint32_t slot = claimFreeSlot(); + m_slots[slot].keep_alive = std::move(keep_alive); writeDescriptors(slot); const AsyncCompute::Ticket ticket = m_core->asyncCompute().submit( @@ -242,6 +327,12 @@ void AsyncComputeTask::step(const std::function& record, std StepContext context(*this, cmd, slot); record(context); + for (const Buffer& buffer : m_buffers) { + if (buffer.host) { + buffer.copies[slot].getAllocation().flush(0, buffer.size); + } + } + // CPU copies of this step for read() bool any_readback = false; for (const Buffer& buffer : m_buffers) { @@ -266,6 +357,7 @@ void AsyncComputeTask::step(const std::function& record, std ); publish(slot, ticket); + return m_slots[slot].step; } auto AsyncComputeTask::read(BufferId id, std::span out) -> bool { @@ -319,7 +411,7 @@ auto AsyncComputeTask::acquireForFrame(vk::CommandBuffer cmd, uint32_t frame_ind sync::BarrierBatch barriers; for (const Buffer& buffer : m_buffers) { if (buffer.ring) { - barriers.acquire(*buffer.copies[slot], next, handoff); + barriers.acquire(*buffer.copies[slot], buffer.acquire_usage.value_or(next), handoff); } } barriers.flush(cmd); diff --git a/engine/src/toast/renderer/async_compute_task.hpp b/engine/src/toast/renderer/async_compute_task.hpp index 609868a8b..2c350095e 100644 --- a/engine/src/toast/renderer/async_compute_task.hpp +++ b/engine/src/toast/renderer/async_compute_task.hpp @@ -66,6 +66,16 @@ class TOAST_API AsyncComputeTask { return m_cmd; } + [[nodiscard]] + auto hostData(BufferId id) const -> std::span; + + template + [[nodiscard]] + auto hostData(BufferId id) const -> std::span { + const std::span bytes = hostData(id); + return {reinterpret_cast(bytes.data()), bytes.size() / sizeof(T)}; + } + private: friend class AsyncComputeTask; @@ -138,7 +148,10 @@ class TOAST_API AsyncComputeTask { /// Constructor only auto addRingBuffer(vk::DeviceSize size, bool cpu_readback = false) -> BufferId; auto addPersistentBuffer(vk::DeviceSize size) -> BufferId; + auto addHostBuffer(vk::DeviceSize size) -> BufferId; void bind(uint32_t binding, BufferId id, Copy copy = Copy::current); + void bindExternal(uint32_t set, uint32_t binding, std::function source); + void setAcquireUsage(BufferId id, sync::Usage usage); /// Owner thread, blocks until the copy landed gpu side void upload(BufferId id, std::span data); @@ -149,7 +162,10 @@ class TOAST_API AsyncComputeTask { } /// Owner thread - void step(const std::function& record, std::string_view debug_name = {}); + auto step( + const std::function& record, std::string_view debug_name = {}, + std::shared_ptr keep_alive = {} + ) -> uint64_t; private: /// Frames in flight plus the step input plus the newest result plus the one being written @@ -157,11 +173,13 @@ class TOAST_API AsyncComputeTask { struct Buffer { bool ring = false; + bool host = false; bool cpu_readback = false; vk::DeviceSize size = 0; /// One per slot for a ring buffer and a single entry otherwise std::vector copies; std::vector readbacks; + std::optional acquire_usage; }; struct Binding { @@ -170,13 +188,20 @@ class TOAST_API AsyncComputeTask { Copy copy = Copy::current; }; + struct ExternalBinding { + uint32_t set = 0; + uint32_t binding = 0; + std::function source; + }; + struct Slot { - vk::raii::DescriptorSet set = nullptr; + std::vector sets; AsyncCompute::Ticket ticket = AsyncCompute::k_no_ticket; /// Frames in flight still binding this slot uint32_t pins = 0; /// Step it holds and 0 while being written uint64_t step = 0; + std::shared_ptr keep_alive; }; void createDescriptors(); @@ -196,6 +221,8 @@ class TOAST_API AsyncComputeTask { std::vector m_buffers; std::vector m_bindings; + std::vector m_externals; + vma::raii::Buffer m_placeholder = nullptr; mutable std::mutex m_mutex; std::array m_slots; diff --git a/engine/src/toast/renderer/passes/debris_pass.cpp b/engine/src/toast/renderer/passes/debris_pass.cpp new file mode 100644 index 000000000..150c4abce --- /dev/null +++ b/engine/src/toast/renderer/passes/debris_pass.cpp @@ -0,0 +1,148 @@ +#include "debris_pass.hpp" + +#include "../gpu_sync.hpp" +#include "../shader_cache.hpp" +#include "../vulkan_core.hpp" +#include "../vulkan_debug.hpp" +#include "../vulkan_renderer.hpp" + +#include +#include +#include +#include +#include +#include + +namespace renderer { + +namespace { + +constexpr uint32_t k_debris_scene_set = 0; +constexpr uint32_t k_debris_set = 1; +constexpr uint32_t k_debris_particles_binding = 0; +constexpr uint32_t k_debris_list_binding = 1; + +} + +DebrisPass::DebrisPass( + const VulkanCore& core, vk::Format depth_format, vk::Extent2D extent, std::shared_ptr simulation +) + : m_core(&core), + m_simulation(std::move(simulation)) { + ZoneScoped; + static_assert(sizeof(PushConstants) == 16, "PushConstants is mirrored by debris_draw.slang"); + + const auto uid = assets::resolveURI("core://shaders/debris_draw.slang"); + const auto shader = uid.has_value() ? ShaderCache::get().acquire(*uid) : nullptr; + if (!shader) { + TOAST_ERROR("Render", "DebrisPass shader core://shaders/debris_draw.slang unavailable; debris will not draw"); + setEnabled(false); + return; + } + + m_layout.rebuild(core, shader->reflection, "DebrisPass"); + const auto& layouts = m_layout.getDescriptorSetLayouts(); + if (layouts.size() <= k_debris_set) { + TOAST_ERROR( + "Render", "DebrisPass shader layout declares {} descriptor sets; it needs the scene and debris sets", layouts.size() + ); + setEnabled(false); + return; + } + + VulkanPipeline::Config config; + config.pipeline_type = VulkanPipeline::PipelineType::graphics; + config.debug_name = "DebrisPass"; + config.color_format = k_voxel_albedo_format; + config.depth_format = depth_format; + config.extent = extent; + config.extra_color_formats = voxelGbufferExtraColorFormats(); + config.write_extra_color = true; + config.write_color_alpha = true; + config.shader_spirv = shader->spirv; + config.pipeline_layout = *m_layout.getPipelineLayout(); + config.topology = vk::PrimitiveTopology::eTriangleList; + config.cull_mode = vk::CullModeFlagBits::eBack; + config.depth_test = true; + config.depth_write = true; + m_pipeline.rebuild(core, config); + + m_scene_sets.create(core, shader->reflection, *layouts[k_debris_scene_set], "DebrisPass"); + + const vk::DescriptorPool pool = VulkanRenderer::instance->getDescriptorPoolHandle(); + const vk::DescriptorSetLayout set_layout = *layouts[k_debris_set]; + for (uint32_t i = 0; i < VulkanRenderer::k_frames_in_flight; ++i) { + auto sets = core.getDevice().allocateDescriptorSets(vk::DescriptorSetAllocateInfo(pool, 1, &set_layout)); + m_sets.push_back(std::move(sets[0])); + setDebugName(core, *m_sets.back(), std::format("DebrisPass Set[{}]", i)); + } + m_bound.assign(VulkanRenderer::k_frames_in_flight, {}); +} + +auto DebrisPass::prepare(vk::CommandBuffer cmd, uint32_t frame_index) -> bool { + ZoneScoped; + m_args = nullptr; + if (!m_pipeline.isReady() || m_simulation == nullptr || !m_simulation->isReady() || frame_index >= m_sets.size() || + !m_scene_sets.get(frame_index)) { + return false; + } + + const auto view = m_simulation->acquireForFrame(cmd, frame_index, sync::Usage::vertex_storage_read); + if (!view || !m_simulation->visible()) { + return false; + } + + const vk::Buffer particles = view.buffer(m_simulation->particlesId()); + const vk::Buffer list = view.buffer(m_simulation->drawListId()); + const vk::Buffer args = view.buffer(m_simulation->drawArgsId()); + if (!particles || !list || !args) { + return false; + } + + if (m_bound[frame_index] != std::array {particles, list}) { + const vk::DescriptorBufferInfo particle_info(particles, 0, VK_WHOLE_SIZE); + const vk::DescriptorBufferInfo list_info(list, 0, VK_WHOLE_SIZE); + const std::array writes { + vk::WriteDescriptorSet( + *m_sets[frame_index], k_debris_particles_binding, 0, 1, vk::DescriptorType::eStorageBuffer, nullptr, &particle_info + ), + vk::WriteDescriptorSet( + *m_sets[frame_index], k_debris_list_binding, 0, 1, vk::DescriptorType::eStorageBuffer, nullptr, &list_info + ), + }; + m_core->getDevice().updateDescriptorSets(writes, {}); + m_bound[frame_index] = {particles, list}; + } + + m_scene_sets.updateTlas(frame_index); + m_args = args; + m_push = PushConstants { + .extrapolation = m_simulation->extrapolation(view.step()), + .frame_delta = m_simulation->frameDelta(), + .voxel_size = voxel::k_voxel_size, + .list_capacity = m_simulation->capacity() * physics::DebrisSimulation::k_voxels_per_particle, + }; + return true; +} + +void DebrisPass::record(vk::CommandBuffer cmd, uint32_t frame_index, uint32_t image_index) { + ZoneScoped; + (void)image_index; + if (!m_args || frame_index >= m_sets.size()) { + return; + } + + const vk::PipelineLayout layout = *m_layout.getPipelineLayout(); + cmd.bindPipeline(vk::PipelineBindPoint::eGraphics, m_pipeline.getPipeline()); + cmd.bindDescriptorSets( + vk::PipelineBindPoint::eGraphics, + layout, + k_debris_scene_set, + std::array {m_scene_sets.get(frame_index), *m_sets[frame_index]}, + {} + ); + cmd.pushConstants(layout, vk::ShaderStageFlagBits::eAll, 0, sizeof(PushConstants), &m_push); + cmd.drawIndirect(m_args, 0, 1, sizeof(vk::DrawIndirectCommand)); +} + +} diff --git a/engine/src/toast/renderer/passes/debris_pass.hpp b/engine/src/toast/renderer/passes/debris_pass.hpp new file mode 100644 index 000000000..2cf03ee40 --- /dev/null +++ b/engine/src/toast/renderer/passes/debris_pass.hpp @@ -0,0 +1,67 @@ +/** + * @file debris_pass.hpp + * @author Xein + * @date 19 Oct 2026 + */ + +#pragma once + +#include "../render_pass_base.hpp" +#include "../scene_descriptor_set.hpp" +#include "../shader_layout.hpp" +#include "../vulkan_pipeline.hpp" + +#include +#include +#include +#include + +namespace physics { +class DebrisSimulation; +} + +namespace renderer { +class VulkanCore; + +class DebrisPass : public IRenderPass { +public: + DebrisPass( + const VulkanCore& core, vk::Format depth_format, vk::Extent2D extent, std::shared_ptr simulation + ); + + [[nodiscard]] + auto stage() const -> RenderStage override { + return RenderStage::voxel_gbuffer; + } + + [[nodiscard]] + auto prepare(vk::CommandBuffer cmd, uint32_t frame_index) -> bool; + + void record(vk::CommandBuffer cmd, uint32_t frame_index, uint32_t image_index) override; + + [[nodiscard]] + auto name() const -> std::string_view override { + return "Debris"; + } + +private: + struct PushConstants { + float extrapolation = 0.0f; + float frame_delta = 0.0f; + float voxel_size = 0.0f; + uint32_t list_capacity = 0; + }; + + const VulkanCore* m_core = nullptr; + std::shared_ptr m_simulation; + + ShaderLayout m_layout; + VulkanPipeline m_pipeline; + SceneDescriptorSets m_scene_sets; + std::vector m_sets; + std::vector> m_bound; + vk::Buffer m_args = nullptr; + PushConstants m_push; +}; + +} diff --git a/engine/src/toast/renderer/voxel_gpu_storage.cpp b/engine/src/toast/renderer/voxel_gpu_storage.cpp index b8462ff2e..bbfc52b97 100644 --- a/engine/src/toast/renderer/voxel_gpu_storage.cpp +++ b/engine/src/toast/renderer/voxel_gpu_storage.cpp @@ -1,5 +1,6 @@ #include "voxel_gpu_storage.hpp" +#include "vulkan_core.hpp" #include "vulkan_debug.hpp" #include @@ -35,15 +36,24 @@ constexpr uint32_t k_min_slot_headroom = 256; constexpr std::array k_min_append_headroom {64 * 1024, 4 * 1024, 64 * 1024, 16 * 1024}; auto createSectionBuffer(const VulkanCore& core, vk::DeviceSize size, std::string_view name) -> vma::raii::Buffer { - const bool concurrent = core.getGraphicsQueueFamilyIndex() != core.getTransferQueueFamilyIndex(); - const std::array family_indices {core.getGraphicsQueueFamilyIndex(), core.getTransferQueueFamilyIndex()}; + // The async compute family reads them for debris collision :3 + std::array family_indices {}; + uint32_t family_count = 0; + for (const uint32_t family : + {core.getGraphicsQueueFamilyIndex(), core.getTransferQueueFamilyIndex(), core.asyncCompute().queueFamilyIndex()}) { + if (std::find(family_indices.begin(), family_indices.begin() + family_count, family) == + family_indices.begin() + family_count) { + family_indices[family_count++] = family; + } + } + const bool concurrent = family_count > 1; vk::BufferCreateInfo buffer_ci {}; buffer_ci.size = size; buffer_ci.usage = vk::BufferUsageFlagBits::eStorageBuffer | vk::BufferUsageFlagBits::eTransferDst; if (concurrent) { buffer_ci.sharingMode = vk::SharingMode::eConcurrent; - buffer_ci.queueFamilyIndexCount = 2; + buffer_ci.queueFamilyIndexCount = family_count; buffer_ci.pQueueFamilyIndices = family_indices.data(); } else { buffer_ci.sharingMode = vk::SharingMode::eExclusive; diff --git a/engine/src/toast/renderer/voxel_gpu_storage.hpp b/engine/src/toast/renderer/voxel_gpu_storage.hpp index e7fb9ab79..fc4c2290b 100644 --- a/engine/src/toast/renderer/voxel_gpu_storage.hpp +++ b/engine/src/toast/renderer/voxel_gpu_storage.hpp @@ -145,6 +145,18 @@ class VoxelGpuStorage : public IVulkanResource { VoxelChangeHistory m_history; }; +struct VoxelCollisionWorld { + struct Volume { + glm::mat4 world_to_voxel {1.0f}; + glm::vec3 bounds_center {0.0f}; + float bounds_radius = 0.0f; + uint32_t record_index = 0; + }; + + std::shared_ptr storage; + std::vector volumes; +}; + /// Mirrors voxel_dda.slang VoxelInstance struct VoxelInstanceGpu { glm::mat4 voxel_to_world {1.0f}; diff --git a/engine/src/toast/renderer/vulkan_renderer.cpp b/engine/src/toast/renderer/vulkan_renderer.cpp index ea60b1012..380c8bb66 100644 --- a/engine/src/toast/renderer/vulkan_renderer.cpp +++ b/engine/src/toast/renderer/vulkan_renderer.cpp @@ -9,6 +9,7 @@ #include "editor_overlays.hpp" #include "frustum.hpp" #include "light_culling.hpp" +#include "passes/debris_pass.hpp" #include "passes/depth_prepass.hpp" #include "passes/environment_pass.hpp" #include "passes/material_pass.hpp" @@ -1468,6 +1469,9 @@ auto VulkanRenderer::recordVoxelScopes( const GpuScope scope(m_gpu_timer.get(), m_current_frame, cmd, "Voxel cells"); m_voxel_pass->recordCellCull(cmd, m_current_frame, scissor.extent); } + + const bool debris_drawn = + m_debris_pass != nullptr && m_debris_pass->isEnabled() && m_debris_pass->prepare(cmd, m_current_frame); sync::BarrierBatch barriers; const auto attachment = [](const SceneColorResources& target, vk::AttachmentLoadOp load, const vk::ClearValue& clear) { @@ -1548,6 +1552,11 @@ auto VulkanRenderer::recordVoxelScopes( const GpuScope scope(m_gpu_timer.get(), m_current_frame, cmd, "Voxel G-buffer"); m_voxel_pass->record(cmd, m_current_frame, 0); } + if (debris_drawn) { + TracyVkZone(m_tracy_vk_ctx, cmd, "Debris"); + const GpuScope scope(m_gpu_timer.get(), m_current_frame, cmd, "Debris"); + m_debris_pass->record(cmd, m_current_frame, 0); + } cmd.endRendering(); if (m_voxel_pass->occlusionActive()) { @@ -3808,6 +3817,13 @@ void VulkanRenderer::tick(float time) noexcept { submitFrame(); } +auto VulkanRenderer::voxelCollisionWorld() const -> std::shared_ptr { + // i need a way of getting this on the physics thread without the engine exploding so just lock it for a sec + // i don't think this is that bad of an idea but maybe it's like bad idk + std::scoped_lock lock(m_voxel_collision_mutex); + return m_voxel_collision_world; +} + void VulkanRenderer::buildVoxelProxies(RenderFrame& frame) { ZoneScoped; @@ -4089,6 +4105,8 @@ void VulkanRenderer::buildVoxelProxies(RenderFrame& frame) { if (!m_voxel_storage) { m_voxel_previous_models.clear(); m_voxel_drawn_models.clear(); + std::scoped_lock lock(m_voxel_collision_mutex); + m_voxel_collision_world.reset(); return; } @@ -4136,6 +4154,25 @@ void VulkanRenderer::buildVoxelProxies(RenderFrame& frame) { drawn_models.insert_or_assign(node_rid, model); } + auto collision = std::make_shared(); + collision->storage = m_voxel_storage; + collision->volumes.reserve(frame.voxel_instances.size()); + const glm::mat4 to_voxel_units = glm::scale(glm::mat4(1.0f), glm::vec3(1.0f / voxel::k_voxel_size)); + for (const VoxelVolumeProxy& proxy : frame.voxel_instances) { + collision->volumes.push_back( + VoxelCollisionWorld::Volume { + .world_to_voxel = to_voxel_units * proxy.inverse_model, + .bounds_center = proxy.bounds_center, + .bounds_radius = proxy.bounds_radius, + .record_index = proxy.record_index, + } + ); + } + { + std::scoped_lock lock(m_voxel_collision_mutex); + m_voxel_collision_world = std::move(collision); + } + m_voxel_drawn_models = std::move(drawn_models); } diff --git a/engine/src/toast/renderer/vulkan_renderer.hpp b/engine/src/toast/renderer/vulkan_renderer.hpp index eb5a70106..fa515cc9a 100644 --- a/engine/src/toast/renderer/vulkan_renderer.hpp +++ b/engine/src/toast/renderer/vulkan_renderer.hpp @@ -81,6 +81,7 @@ class ReflectionProbePass; class DepthPrepass; class SkinningPass; class VoxelPass; +class DebrisPass; class RayTracingScene; class SkinnedBlasPool; @@ -776,6 +777,11 @@ class VulkanRenderer { /// Still registered with addRenderPass so it lists and toggles like any pass void setVoxelPass(VoxelPass* pass) noexcept { m_voxel_pass = pass; } + void setDebrisPass(DebrisPass* pass) noexcept { m_debris_pass = pass; } + + [[nodiscard]] + auto voxelCollisionWorld() const -> std::shared_ptr; + void setReflectionProbePass(ReflectionProbePass* pass) noexcept { m_reflection_probe_pass = pass; } [[nodiscard]] @@ -1231,6 +1237,8 @@ class VulkanRenderer { VoxelPass* m_voxel_pass = nullptr; + DebrisPass* m_debris_pass = nullptr; + std::atomic_bool m_probe_bake_requested {false}; std::atomic m_probe_stale_count {0}; /// probe = cursor / 6 and face = cursor % 6 or -1 when idle @@ -1353,6 +1361,9 @@ class VulkanRenderer { std::shared_ptr m_voxel_storage; std::shared_ptr m_voxel_storage_pending; + mutable std::mutex m_voxel_collision_mutex; + std::shared_ptr m_voxel_collision_world; + struct VoxelSceneKey { uint64_t node_rid = 0; uint32_t revision = 0; diff --git a/engine/src/toast/voxel/assets/voxel_palette.cpp b/engine/src/toast/voxel/assets/voxel_palette.cpp index 37b606ed9..d39639e68 100644 --- a/engine/src/toast/voxel/assets/voxel_palette.cpp +++ b/engine/src/toast/voxel/assets/voxel_palette.cpp @@ -56,6 +56,8 @@ auto VoxelPalette::materialLibrary() const -> const voxel::MaterialLibrary& { out.density = static_cast(std::lround(density)); out.toughness = d.toughness(); out.hardness = d.hardness(); + out.fracture_pattern = d.fracturePatterh(); + out.fracture_profile = d.fractureProfile(); out.structural_strength = d.structuralStrength(); out.burn_rate = d.burnRate(); if (not d.destructible()) { diff --git a/engine/src/toast/voxel/nodes/procedural_voxel.cpp b/engine/src/toast/voxel/nodes/procedural_voxel.cpp index b8f4723e2..a0a2d9019 100644 --- a/engine/src/toast/voxel/nodes/procedural_voxel.cpp +++ b/engine/src/toast/voxel/nodes/procedural_voxel.cpp @@ -13,6 +13,7 @@ #include #include #include +#include #include #include #include @@ -29,21 +30,22 @@ namespace { using _detail::assetOfType; -void hashCombine(uint64_t& seed, uint64_t value) noexcept { - // splitmix64 finaliser - value += 0x9e3779b97f4a7c15ull + seed; - value = (value ^ (value >> 30)) * 0xbf58476d1ce4e5b9ull; - value = (value ^ (value >> 27)) * 0x94d049bb133111ebull; - seed = value ^ (value >> 31); -} - -void hashFloats(uint64_t& seed, const float* data, size_t count) noexcept { - for (size_t i = 0; i < count; ++i) { - uint32_t bits = 0; - std::memcpy(&bits, &data[i], sizeof(bits)); - hashCombine(seed, bits); - } -} +// moved to hash.hpp 16 Oct +// void hashCombine(uint64_t& seed, uint64_t value) noexcept { +// // splitmix64 finaliser +// value += 0x9e3779b97f4a7c15ull + seed; +// value = (value ^ (value >> 30)) * 0xbf58476d1ce4e5b9ull; +// value = (value ^ (value >> 27)) * 0x94d049bb133111ebull; +// seed = value ^ (value >> 31); +// } +// +// void hashFloats(uint64_t& seed, const float* data, size_t count) noexcept { +// for (size_t i = 0; i < count; ++i) { +// uint32_t bits = 0; +// std::memcpy(&bits, &data[i], sizeof(bits)); +// hashCombine(seed, bits); +// } +// } [[nodiscard]] auto paletteAsset(uint64_t uid) -> const voxel::Palette* { @@ -444,20 +446,20 @@ auto ProceduralVoxel::toolPiece() -> VoxelPiece* { auto ProceduralVoxel::keyOf(const std::vector& pieces) -> uint64_t { uint64_t key = 0x70726f63u; - hashCombine(key, paletteUid()); + toast::hashCombine(key, paletteUid()); for (const Collected& entry : pieces) { - hashCombine(key, entry.piece->uid().data()); - hashCombine(key, reinterpret_cast(entry.piece->info())); - hashCombine(key, entry.piece->inputRevision()); + toast::hashCombine(key, entry.piece->uid().data()); + toast::hashCombine(key, reinterpret_cast(entry.piece->info())); + toast::hashCombine(key, entry.piece->inputRevision()); hashFloats(key, &entry.to_root[0][0], 16); } std::vector buckets; collectBuckets(*this, glm::mat4(1.0f), buckets); for (const BucketFill& fill : buckets) { - hashCombine(key, fill.bucket->uid().data()); - hashCombine(key, fill.bucket->colorId()); - hashCombine( + toast::hashCombine(key, fill.bucket->uid().data()); + toast::hashCombine(key, fill.bucket->colorId()); + toast::hashCombine( key, static_cast(static_cast(fill.seed.x)) ^ (static_cast(static_cast(fill.seed.y)) << 21) ^ @@ -469,8 +471,8 @@ auto ProceduralVoxel::keyOf(const std::vector& pieces) -> uint64_t { if (!participatesIn(NodeOwnerParticipation::gameplay_tick)) { const renderer::EditorOverlays& overlays = renderer::editorOverlays(); if (VoxelPiece* tool = toolPiece()) { - hashCombine(key, overlays.tool_box_kind); - hashCombine(key, tool->inputRevision()); + toast::hashCombine(key, overlays.tool_box_kind); + toast::hashCombine(key, tool->inputRevision()); const std::array corners = {glm::vec3(overlays.tool_box_min), glm::vec3(overlays.tool_box_max)}; hashFloats(key, &corners[0].x, 6); } diff --git a/engine/src/toast/voxel/palette.hpp b/engine/src/toast/voxel/palette.hpp index 54b7f5435..646fa0b41 100644 --- a/engine/src/toast/voxel/palette.hpp +++ b/engine/src/toast/voxel/palette.hpp @@ -9,6 +9,7 @@ #include #include +#include #include #include #include @@ -98,6 +99,8 @@ struct PhysicalMaterial { /// Damage that breaks only softer tiers never breaks this material physics::Hardness hardness = physics::material_defaults::k_hardness; + physics::FracturePattern fracture_pattern = physics::material_defaults::k_fracture_pattern; + physics::PackedFractureProfile fracture_profile = physics::material_defaults::k_fracture_profile; /// kJ/m³ it takes to break per m3 float toughness = physics::material_defaults::k_toughness; diff --git a/engine/src/toast/world/play_workspace.cpp b/engine/src/toast/world/play_workspace.cpp index 3f62e8aeb..83aa51901 100644 --- a/engine/src/toast/world/play_workspace.cpp +++ b/engine/src/toast/world/play_workspace.cpp @@ -56,6 +56,7 @@ PlayWorkspace::PlayWorkspace(UID handle, assets::Prefab& prefab) : Workspace(han PlayWorkspace::~PlayWorkspace() { s_instances.fetch_sub(1, std::memory_order_release); + physics::clearDebris(); // need to clear when paused if (not m_root_node.exists()) { return; } @@ -113,6 +114,7 @@ void PlayWorkspace::tick() { physics::Simulator::recordTickBurst(step_result.steps, step_result.time_budget_reached); physics::Simulator::recordInterpolationAlpha(step_result.alpha); physics::Simulator::syncRenderPoses(); + physics::stepDebris(step_result.steps); m_scheduler.runPhase(m_scheduler.schedule.post_physics, TickFunctionList::post_physics, "post_physics"); m_scheduler.runPhase(m_scheduler.schedule.late_tick, TickFunctionList::late_tick, "late_tick"); diff --git a/engine/src/toast/world/world.cpp b/engine/src/toast/world/world.cpp index 9fc2c7bda..9ced467ef 100644 --- a/engine/src/toast/world/world.cpp +++ b/engine/src/toast/world/world.cpp @@ -113,6 +113,7 @@ void World::tick() { physics::Simulator::recordTickBurst(step_result.steps, step_result.time_budget_reached); physics::Simulator::recordInterpolationAlpha(step_result.alpha); physics::Simulator::syncRenderPoses(); + physics::stepDebris(step_result.steps); // TODO Is this class really needed? m_scheduler.runPhase(m_scheduler.schedule.post_physics, TickFunctionList::post_physics, "post_physics");