From 33d36d218185df8f3a0fe1368eff21e8a9784d26 Mon Sep 17 00:00:00 2001 From: Xein Date: Thu, 22 Oct 2026 22:49:01 +0200 Subject: [PATCH 1/5] need to optimize this ong --- engine/src/toast/physics/debris.hpp | 1 + engine/src/toast/physics/physics_settings.cpp | 47 +++ engine/src/toast/physics/physics_settings.hpp | 5 + engine/src/toast/physics/simulator.cpp | 392 +++++++++++++++--- engine/src/toast/physics/simulator.hpp | 6 +- 5 files changed, 384 insertions(+), 67 deletions(-) diff --git a/engine/src/toast/physics/debris.hpp b/engine/src/toast/physics/debris.hpp index a4732f27c..cc4240e3a 100644 --- a/engine/src/toast/physics/debris.hpp +++ b/engine/src/toast/physics/debris.hpp @@ -56,6 +56,7 @@ struct DebrisBlast { glm::vec3 start {}; glm::vec3 end {}; float energy = 0.0f; + float max_speed = 0.0f; }; inline constexpr float k_max_debris_speed = 40.0f; diff --git a/engine/src/toast/physics/physics_settings.cpp b/engine/src/toast/physics/physics_settings.cpp index c95e55502..87c57052a 100644 --- a/engine/src/toast/physics/physics_settings.cpp +++ b/engine/src/toast/physics/physics_settings.cpp @@ -378,6 +378,53 @@ void registerPhysicsSettings() { .max = 8.0, .step = 0.25}, &Tunables::explosion_falloff); + flt("physics.fracture.explosion_chunk_size", + 1.0, + {.label = "Explosion chunk size", + .category = "Fracture", + .description = "Meters across the chunks an explosion breaks material into", + .min = 0.2, + .max = 4.0, + .step = 0.1}, + &Tunables::explosion_chunk_size); + flt("physics.fracture.explosion_debris_ratio", + 8.0, + {.label = "Explosion debris ratio", + .category = "Fracture", + .description = "Material hit by a blast this many times its toughness turns to debris instead of chunks", + .min = 1.0, + .max = 64.0, + .step = 0.5}, + &Tunables::explosion_debris_ratio); + flt("physics.fracture.explosion_shatter_width", + 0.6, + {.label = "Explosion shatter width", + .category = "Fracture", + .description = "Meters of cracked material around the hole an explosion leaves, 0 for none", + .min = 0.0, + .max = 4.0, + .step = 0.1}, + &Tunables::explosion_shatter_width); + flt("physics.fracture.explosion_max_speed", + 20.0, + {.label = "Explosion max speed", + .category = "Fracture", + .description = "Fastest an explosion throws a chunk, debris or body, in m/s. Faster tunnels through thin colliders", + .min = 1.0, + .max = 100.0, + .step = 1.0}, + &Tunables::explosion_max_speed); + uint_setting( + "physics.fracture.explosion_max_fragments", + 48, + {.label = "Explosion max fragments", + .category = "Fracture", + .description = "Chunks one explosion can promote to their own bodies per shape; the rest become debris", + .min = 0.0, + .max = 256.0, + .step = 1.0}, + &Tunables::explosion_max_fragments + ); uint_setting( "physics.fracture.impact_max_hardness", 1, diff --git a/engine/src/toast/physics/physics_settings.hpp b/engine/src/toast/physics/physics_settings.hpp index b0dd95506..be1738851 100644 --- a/engine/src/toast/physics/physics_settings.hpp +++ b/engine/src/toast/physics/physics_settings.hpp @@ -55,6 +55,11 @@ struct Tunables { float debris_friction = 0.6f; float explosion_max_radius = 8.0f; float explosion_falloff = 2.0f; + float explosion_chunk_size = 1.0f; + float explosion_debris_ratio = 8.0f; + float explosion_shatter_width = 0.6f; + uint32_t explosion_max_fragments = 48; + float explosion_max_speed = 20.0f; uint32_t impact_max_hardness = 1; uint32_t min_bounds_per_job = 32; uint32_t min_candidates_per_job = 2; diff --git a/engine/src/toast/physics/simulator.cpp b/engine/src/toast/physics/simulator.cpp index 9ef0eec03..7f8b73031 100644 --- a/engine/src/toast/physics/simulator.cpp +++ b/engine/src/toast/physics/simulator.cpp @@ -36,6 +36,7 @@ #include #include #include +#include #include namespace physics { @@ -868,7 +869,11 @@ void Simulator::emitDebris( 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); + float speed = debrisSpeed(energy_each, mass, speed_scale); + if (blast->max_speed > 0.0f) { + speed = std::min(speed, blast->max_speed); + } + velocity += debrisConeDirection(axis, rng, k_debris_cone_spread) * speed; } 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}); @@ -4555,14 +4560,20 @@ auto Simulator::explode(const ExplosionDamage& explosion) -> uint32_t { if (shape == nullptr || not shape->enabled) { continue; } + + const BodyID owner = shape->owner; if (shape->type == ShapeType::voxel) { moved += explodeShape(shape_id, explosion); - continue; + // add impulseeeeeeeee + shape = tryGetShape(shape_id); + if (shape == nullptr || not shape->enabled || shape->owner != owner) { + continue; + } } - const BodyID owner = shape->owner; const Body* body = tryGetBody(owner); - if (body == nullptr || body->type != BodyType::dynamic_body || std::ranges::find(pushed, owner) != pushed.end()) { + if (body == nullptr || not body->enabled || body->type != BodyType::dynamic_body || + std::ranges::find(pushed, owner) != pushed.end()) { continue; } const glm::vec3 offset = body->worldCenterOfMass() - explosion.point; @@ -4578,7 +4589,7 @@ auto Simulator::explode(const ExplosionDamage& explosion) -> uint32_t { const float pressure = k_joules_per_kilojoule * explosionDensityAt(explosion.force, std::max(distance, 0.5f), explosion.falloff); const float impulse = pressure * area * static_cast(Accumulator::fixedDelta()); - pushBody(owner, body->worldCenterOfMass(), away, std::numeric_limits::infinity(), impulse); + pushBody(owner, body->worldCenterOfMass(), away, tunables().explosion_max_speed, impulse); } return moved; } @@ -4753,6 +4764,8 @@ auto Simulator::smashShape(ShapeID shape_id, const SmashVolume& volume, const Ca } auto Simulator::explodeShape(ShapeID shape_id, const ExplosionDamage& explosion) -> uint32_t { + ZoneScopedN("physics::ExplodeShape"); + const Shape* shape = tryGetShape(shape_id); VoxelShapeData* data = shape != nullptr ? tryGetVoxelData(shape->voxel.data) : nullptr; const Body* body = shape != nullptr ? tryGetBody(shape->owner) : nullptr; @@ -4760,84 +4773,330 @@ auto Simulator::explodeShape(ShapeID shape_id, const ExplosionDamage& explosion) return 0; } - const glm::quat to_volume = glm::inverse(shape->voxel.local_rotation) * glm::inverse(body->rotation); const glm::vec3 local_point = glm::inverse(shape->voxel.local_rotation) * ((glm::inverse(body->rotation) * (explosion.point - body->position)) - shape->voxel.local_center); - const glm::quat to_world = glm::inverse(to_volume); + const uint32_t solid_before = data->solid_voxel_count; + const uint64_t seed = fractureSeed(BodyID {}, shape_id, local_point, m_tick_index); - SmashPieces picked; + struct BrickMask { + glm::ivec3 brick {0}; + voxel::BrickOccupancy mask {}; + }; + + std::vector debris_voxels; + std::unordered_map debris_bricks; + std::unordered_map> chunk_cells; + glm::vec3 debris_index_sum {0.0f}; + glm::vec3 chunk_index_sum {0.0f}; + uint32_t chunk_voxels = 0; + uint32_t crack_voxels = 0; + float debris_energy = 0.0f; float reach = 0.0f; + { std::scoped_lock voxel_lock {voxelDataMutex()}; + const voxel::Volume& volume = *data->volume; + const voxel::MaterialLibrary& materials = data->materials; + if (materials.materials.empty()) { + return 0; + } reach = explosionReach( - explosion.force, - explosion.falloff, - weakestToughness(data->materials, explosion.max_hardness), - tunables().explosion_max_radius + explosion.force, explosion.falloff, weakestToughness(materials, explosion.max_hardness), tunables().explosion_max_radius ); if (not(reach > 0.0f)) { return 0; } - picked = collectBrokenPieces( - *data->volume, - data->palette, - data->materials, - local_point - glm::vec3(reach), - local_point + glm::vec3(reach), - [&](const glm::vec3& center, const voxel::PhysicalMaterial& material) { - const float distance = glm::length(center - local_point); - return distance <= reach && - hitBreaks(material, explosionDensityAt(explosion.force, distance, explosion.falloff), explosion.max_hardness); - } - ); + + const std::optional hit = hitMaterial(volume, data->palette, materials, local_point, reach); + const FractureValues profile = hit.has_value() ? hit->fracture_profile.unpack() : FractureValues {}; + Crater edge {.start = local_point, .end = local_point, .radius = reach, .seed = seed}; + if (hit.has_value()) { + edge.edge_noise = std::clamp(profile.edge_noise, 0.0f, k_max_edge_noise); + edge.edge_noise_scale = std::max(profile.edge_noise_scale, 0.0f) * voxel::k_voxel_size; + edge.edge_lobes = std::clamp(profile.edge_lobes, 0.0f, k_max_edge_noise); + } + const bool cracks_around = hit.has_value() && patternShatters(hit->fracture_pattern); + const float band_width = cracks_around ? std::max(tunables().explosion_shatter_width, 0.0f) : 0.0f; + const float scan = craterMaxRadius(edge) + band_width; + + const glm::ivec3 voxel_dims = glm::ivec3(volume.voxelDims()); + glm::ivec3 first = glm::ivec3(glm::floor((local_point - glm::vec3(scan)) / voxel::k_voxel_size)); + glm::ivec3 last = glm::ivec3(glm::ceil((local_point + glm::vec3(scan)) / 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) { + return 0; + } + + // Voronoi + const float cell_size = std::max(tunables().explosion_chunk_size, 2.0f * voxel::k_voxel_size); + const float crack_gap = crackThickness(); + const float debris_ratio = std::max(tunables().explosion_debris_ratio, 1.0f); + const float release = hit.has_value() ? std::clamp(profile.shatter_release, 0.0f, 1.0f) : 0.0f; + const uint64_t jitter_seed = streamSeed(seed, FractureStream::shatter_points); + const uint64_t release_seed = streamSeed(seed, FractureStream::granular); + const auto released = [&](const glm::ivec3& cell) { return hash01(release_seed, cell) < release; }; + constexpr uint64_t k_coarse_bit = 1ull << 63; + const auto cell_key = [](const glm::ivec3& cell, bool coarse) { + return voxelKey(cell + glm::ivec3(1 << 19)) | (coarse ? k_coarse_bit : 0ull); + }; + std::unordered_map seed_points; + const auto seed_point = [&](const glm::ivec3& cell, bool coarse) { + const auto [it, inserted] = seed_points.try_emplace(cell_key(cell, coarse)); + if (inserted) { + const uint64_t level_seed = jitter_seed + (coarse ? 8 : 0); + const glm::vec3 jitter { + 0.2f + (0.6f * hash01(level_seed + 1, cell)), + 0.2f + (0.6f * hash01(level_seed + 2, cell)), + 0.2f + (0.6f * hash01(level_seed + 3, cell)), + }; + it->second = (glm::vec3(cell) + jitter) * (coarse ? 2.0f * cell_size : cell_size); + } + return it->second; + }; + + const auto brick_dim = static_cast(voxel::k_brick_dim); + const glm::ivec3 first_brick = first / brick_dim; + const glm::ivec3 last_brick = last / brick_dim; + const auto mark = [&](BrickMask& entry, const glm::ivec3& v, const glm::ivec3& brick) { + entry.brick = brick; + const glm::ivec3 local = v - (brick * brick_dim); + voxel::setSolid( + entry.mask, static_cast(local.x), static_cast(local.y), static_cast(local.z), true + ); + }; + + 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) { + const glm::ivec3 brick {bx, by, bz}; + const voxel::BrickOccupancy* occupancy = volume.occupancyPointer(brick); + if (occupancy == nullptr) { + continue; + } + + 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) { + for (int32_t y = brick_first.y; y <= brick_last.y; ++y) { + for (int32_t x = brick_first.x; x <= brick_last.x; ++x) { + const glm::ivec3 v {x, y, z}; + const glm::ivec3 local = v - (brick * brick_dim); + if (not voxel::isSolid( + *occupancy, static_cast(local.x), static_cast(local.y), static_cast(local.z) + )) { + continue; + } + + const glm::vec3 center = (glm::vec3 {v} + 0.5f) * voxel::k_voxel_size; + const float distance = glm::length(center - local_point); + if (distance > scan) { + continue; + } + + const uint8_t palette_index = volume.materialAt(v); + const voxel::PhysicalMaterial& material = + materials.materials[voxel::resolveMaterialIndex(data->palette, materials, palette_index)]; + if (material.isIndestructible() || not material.breakableBy(explosion.max_hardness)) { + continue; + } + + const float edge_radius = craterRadiusAt(edge, center); + const float reached = distance * (reach / std::max(edge_radius, 1.0e-4f)); + const float density = explosionDensityAt(explosion.force, reached, explosion.falloff); + const bool broken = density >= material.toughness; + if (not broken) { + // cracks just past the hole + const float hole = material.toughness > 0.0f + ? std::pow(explosion.force / material.toughness, 1.0f / explosion.falloff) + : reach; + if (not(reached <= std::min(hole, reach) + band_width)) { + continue; + } + } + + bool debris = broken && (material.toughness > 0.0f ? density >= debris_ratio * material.toughness : true); + glm::ivec3 chunk {0}; + bool coarse = false; + if (not debris) { + coarse = broken && reached > 0.5f * reach; + const glm::ivec3 home = glm::ivec3(glm::floor(center / (coarse ? 2.0f * cell_size : cell_size))); + float nearest = std::numeric_limits::max(); + float second = nearest; + glm::ivec3 next {0}; + for (int32_t dz = -1; dz <= 1; ++dz) { + for (int32_t dy = -1; dy <= 1; ++dy) { + for (int32_t dx = -1; dx <= 1; ++dx) { + const glm::ivec3 cell = home + glm::ivec3 {dx, dy, dz}; + const float d = glm::distance(center, seed_point(cell, coarse)); + if (d < nearest) { + second = nearest; + next = chunk; + nearest = d; + chunk = cell; + } else if (d < second) { + second = d; + next = cell; + } + } + } + } + + // the hole needs no cracks + if (not broken) { + const bool loose = released(chunk); + if (second - nearest < crack_gap) { + if (not(loose || released(next))) { + continue; + } + debris = true; + ++crack_voxels; + } else if (not loose) { + continue; + } + } + } + + if (debris) { + mark(debris_bricks[voxelKey(brick)], v, brick); + debris_voxels.push_back({.voxel = v, .material = palette_index}); + debris_index_sum += glm::vec3(v); + debris_energy += std::max(density - material.toughness, 0.0f) * k_voxel_volume; + } else { + mark(chunk_cells[cell_key(chunk, coarse)][voxelKey(brick)], v, brick); + chunk_index_sum += glm::vec3(v); + ++chunk_voxels; + } + } + } + } + } + } + } } - // every piece flies straight away from the blast - const glm::vec3 lattice_origin = body->position + (body->rotation * shape->voxel.local_center); - const float dt = static_cast(Accumulator::fixedDelta()); - return breakPieces( + if (debris_voxels.empty() && chunk_voxels == 0) { + return 0; + } + + constexpr float k_explosion_debris_share = 0.2f; + if (not debris_voxels.empty()) { + const DebrisBlast blast { + .start = local_point, + .end = local_point, + .energy = debris_energy * k_explosion_debris_share, + .max_speed = tunables().explosion_max_speed, + }; + emitDebris(shape_id, debris_voxels, &blast, nullptr, seed); + for (const auto& [key, entry] : debris_bricks) { + shape = tryGetShape(shape_id); + data = shape != nullptr ? tryGetVoxelData(shape->voxel.data) : nullptr; + if (data == nullptr || data->volume == nullptr) { + break; + } + DetachedComponent piece {.component = 0, .voxel_count = voxel::popCount(entry.mask)}; + piece.pieces.push_back(voxel::BrickPiece {.brick = entry.brick, .voxels = entry.mask, .component = 0}); + clearFragmentFromSource(shape_id, *data, *data->volume, piece); + } + } + + const AABB bounds {.min = explosion.point - glm::vec3(reach), .max = explosion.point + glm::vec3(reach)}; + SmashPieces chunks; + chunks.index_sum = chunk_index_sum; + chunks.voxels = chunk_voxels; + for (const auto& [cell, bricks] : chunk_cells) { + DetachedComponent piece; + piece.component = static_cast(chunks.pieces.size()); + for (const auto& [key, entry] : bricks) { + piece.voxel_count += voxel::popCount(entry.mask); + piece.pieces.push_back(voxel::BrickPiece {.brick = entry.brick, .voxels = entry.mask, .component = piece.component}); + } + chunks.pieces.push_back(std::move(piece)); + } + const size_t chunk_count = chunks.pieces.size(); + breakPieces( shape_id, - picked, + chunks, true, - [&](const DetachedComponent& piece) -> std::optional { - glm::vec3 index_sum {0.0f}; - uint32_t count = 0; - for (const voxel::BrickPiece& brick_piece : piece.pieces) { - const glm::ivec3 base = brick_piece.brick * static_cast(voxel::k_brick_dim); - for (uint32_t i = 0; i < voxel::k_brick_voxel_count; ++i) { - const voxel::BrickCoord coord = voxel::localFromIndex(i); - if (voxel::isSolid(brick_piece.voxels, coord.x, coord.y, coord.z)) { - index_sum += glm::vec3(base) + glm::vec3(coord.x, coord.y, coord.z); - ++count; - } - } - } - if (count == 0) { - return std::nullopt; - } - const glm::vec3 local_center = ((index_sum / static_cast(count)) + 0.5f) * voxel::k_voxel_size; - const glm::vec3 world_center = lattice_origin + (to_world * local_center); - const glm::vec3 offset = world_center - explosion.point; - const float distance = glm::length(offset); - - // pressure on the side of the piece facing the blast - const float area = std::pow(static_cast(count), 2.0f / 3.0f) * voxel::k_voxel_size * voxel::k_voxel_size; - const float pressure = - k_joules_per_kilojoule * explosionDensityAt(explosion.force, std::max(distance, 0.5f), explosion.falloff); - return FragmentPush { - .point = explosion.point, - .direction = distance > 1.0e-4f ? offset / distance : glm::vec3 {0.0f, 0.0f, 1.0f}, - .speed = std::numeric_limits::infinity(), - .max_impulse = pressure * area * dt, - }; - }, - AABB {.min = explosion.point - glm::vec3(reach), .max = explosion.point + glm::vec3(reach)} + [&](const DetachedComponent& piece) -> std::optional { return explosionPush(shape_id, piece, explosion); }, + bounds, + tunables().explosion_max_fragments + ); + + shape = tryGetShape(shape_id); + data = shape != nullptr ? tryGetVoxelData(shape->voxel.data) : nullptr; + if (data == nullptr) { + return solid_before; + } + data->connectivity_dirty = true; + if (toast::VoxelNode* node = voxelNodeFor(shape_id); node != nullptr && not debris_voxels.empty()) { + node->recordDamage(static_cast(debris_voxels.size()), debris_index_sum); + } + unlockSleep(shape->owner); + wakeBodiesInBounds(bounds); + + TOAST_INFO( + "Physics", + "Explosion: force {:.1f} reach {:.2f} m debris_voxels {} (cracks {}) chunk_voxels {} chunks {} debris_energy {:.3f} kJ", + explosion.force, + reach, + debris_voxels.size(), + crack_voxels, + chunk_voxels, + chunk_count, + debris_energy ); + + const uint32_t solid_after = data->solid_voxel_count; + return solid_before - std::min(solid_before, solid_after); +} + +auto Simulator::explosionPush(ShapeID shape_id, const DetachedComponent& piece, const ExplosionDamage& explosion) + -> std::optional { + const Shape* shape = tryGetShape(shape_id); + const Body* body = shape != nullptr ? tryGetBody(shape->owner) : nullptr; + if (body == nullptr) { + return std::nullopt; + } + + glm::vec3 index_sum {0.0f}; + uint32_t count = 0; + for (const voxel::BrickPiece& brick_piece : piece.pieces) { + const glm::ivec3 base = brick_piece.brick * static_cast(voxel::k_brick_dim); + for (uint32_t i = 0; i < voxel::k_brick_voxel_count; ++i) { + const voxel::BrickCoord coord = voxel::localFromIndex(i); + if (voxel::isSolid(brick_piece.voxels, coord.x, coord.y, coord.z)) { + index_sum += glm::vec3(base) + glm::vec3(coord.x, coord.y, coord.z); + ++count; + } + } + } + if (count == 0) { + return std::nullopt; + } + + const glm::vec3 local_center = ((index_sum / static_cast(count)) + 0.5f) * voxel::k_voxel_size; + const glm::vec3 lattice_origin = body->position + (body->rotation * shape->voxel.local_center); + const glm::vec3 world_center = lattice_origin + (body->rotation * (shape->voxel.local_rotation * local_center)); + const glm::vec3 offset = world_center - explosion.point; + const float distance = glm::length(offset); + + // pressure on the side of the piece facing the blast + const float area = std::pow(static_cast(count), 2.0f / 3.0f) * voxel::k_voxel_size * voxel::k_voxel_size; + const float pressure = + k_joules_per_kilojoule * explosionDensityAt(explosion.force, std::max(distance, 0.5f), explosion.falloff); + return FragmentPush { + .point = explosion.point, + .direction = distance > 1.0e-4f ? offset / distance : glm::vec3 {0.0f, 0.0f, 1.0f}, + .speed = tunables().explosion_max_speed, + .max_impulse = pressure * area * static_cast(Accumulator::fixedDelta()), + }; } auto Simulator::breakPieces( ShapeID shape_id, SmashPieces& picked, bool chunks, - const std::function(const DetachedComponent&)>& push_for, const AABB& wake_bounds + const std::function(const DetachedComponent&)>& push_for, const AABB& wake_bounds, + uint32_t max_spawns ) -> uint32_t { const Shape* shape = tryGetShape(shape_id); VoxelShapeData* data = shape != nullptr ? tryGetVoxelData(shape->voxel.data) : nullptr; @@ -4845,13 +5104,13 @@ auto Simulator::breakPieces( return 0; } const uint32_t solid_before = data->solid_voxel_count; + const size_t spawn_cap = max_spawns != 0 ? max_spawns : tunables().max_fragment_spawns_per_step; std::ranges::sort(picked.pieces, std::greater {}, &DetachedComponent::voxel_count); size_t flown = 0; for (const DetachedComponent& piece : picked.pieces) { - const bool may_fly = - chunks && flown < tunables().max_fragment_spawns_per_step && piece.voxel_count >= tunables().min_fragment_voxels; + const bool may_fly = chunks && flown < spawn_cap && piece.voxel_count >= tunables().min_fragment_voxels; if (may_fly) { const std::optional push = push_for(piece); const size_t before = m_fragments.size(); @@ -4861,7 +5120,8 @@ auto Simulator::breakPieces( } } - if (piece.voxel_count < tunables().min_fragment_voxels) { + const bool wanted_body = chunks && piece.voxel_count >= tunables().min_fragment_voxels; + if (piece.voxel_count < tunables().min_fragment_voxels || wanted_body) { const std::optional debris_push = push_for(piece); emitComponentDebris(shape_id, piece, debris_push.has_value() ? &*debris_push : nullptr); } diff --git a/engine/src/toast/physics/simulator.hpp b/engine/src/toast/physics/simulator.hpp index 228b8f9e7..a56a4c0e9 100644 --- a/engine/src/toast/physics/simulator.hpp +++ b/engine/src/toast/physics/simulator.hpp @@ -601,9 +601,13 @@ class TOAST_API Simulator { 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; auto explodeShape(ShapeID shape_id, const ExplosionDamage& explosion) -> uint32_t; + [[nodiscard]] + auto explosionPush(ShapeID shape_id, const DetachedComponent& piece, const ExplosionDamage& explosion) + -> std::optional; auto breakPieces( ShapeID shape_id, SmashPieces& picked, bool chunks, - const std::function(const DetachedComponent&)>& push_for, const AABB& wake_bounds + const std::function(const DetachedComponent&)>& push_for, const AABB& wake_bounds, + uint32_t max_spawns = 0 ) -> uint32_t; [[nodiscard]] auto marchVoxelRay(const glm::vec3& origin, const glm::vec3& direction, float max_distance) -> std::optional; From aabc4e7fd365676dbe7cb09ba98c6f431298a398 Mon Sep 17 00:00:00 2001 From: Xein Date: Fri, 23 Oct 2026 11:22:19 +0200 Subject: [PATCH 2/5] Procedural nodes --- editor/VoxelEditor/VoxelEditorActions.cs | 74 ++- engine/src/toast/physics/raycast.cpp | 5 + engine/src/toast/physics/simulator.cpp | 243 +++++--- engine/src/toast/physics/simulator.hpp | 12 +- engine/src/toast/renderer/vulkan_renderer.cpp | 50 +- engine/src/toast/renderer/vulkan_renderer.hpp | 27 +- .../toast/voxel/nodes/procedural_voxel.cpp | 535 +++++++++++++----- .../toast/voxel/nodes/procedural_voxel.hpp | 32 +- engine/src/toast/voxel/nodes/voxel_node.cpp | 188 +++++- engine/src/toast/voxel/nodes/voxel_node.hpp | 81 +++ engine/src/toast/world/workspace.cpp | 9 +- engine/src/toast/world/workspace.hpp | 2 +- engine/src/toast/world/workspace_events.cpp | 16 + engine/src/toast/world/workspace_events.hpp | 9 + engine/src/toast/world/workspace_voxel.cpp | 269 +++++++-- protos/workspace_events.proto | 8 + 16 files changed, 1265 insertions(+), 295 deletions(-) diff --git a/editor/VoxelEditor/VoxelEditorActions.cs b/editor/VoxelEditor/VoxelEditorActions.cs index 8d99da85f..c0a83eee0 100644 --- a/editor/VoxelEditor/VoxelEditorActions.cs +++ b/editor/VoxelEditor/VoxelEditorActions.cs @@ -1,5 +1,7 @@ using System; +using System.Collections.Generic; using System.IO; +using System.Linq; using System.Threading.Tasks; using editor.Assets; using editor.Assets.Types; @@ -25,6 +27,17 @@ public static void Register() { private static async void OnBakeCompleted(VoxelBakeCompleted completed) { try { + if (completed.Unused.Count > 0) { + foreach (var unused in completed.Unused) { + var unusedPath = ProjectContext.Resolve(unused); + AssetDatabase.TryDelete(unusedPath); + AssetDatabase.TryDelete(unusedPath + ".meta"); + } + AssetDatabase.RebuildAssetDatabase(); + Events.Send(new ReloadAssetsManifest()); + } + if (string.IsNullOrEmpty(completed.Path)) return; + var realPath = ProjectContext.Resolve(completed.Path); var header = MetaFile.ReadHeader(realPath); if (header is null) return; @@ -44,17 +57,68 @@ private static async Task BakeAsync(HierarchyElement node) { return; } - var virtualPath = await App.Modals.ShowSaveFile($"{node.Name}_baked", ".tvox"); - if (virtualPath is null) return; - CreateVoxelAsset(virtualPath); - Events.Send(new VoxelBake { Target = node.Uid, Path = virtualPath, Replace = true }); + var chosen = await App.Modals.ShowSaveFile($"{node.Name}_baked", ".tnode"); + if (chosen is null) return; + + var folder = chosen.EndsWith(PrefabExtension, StringComparison.OrdinalIgnoreCase) + ? chosen[..^PrefabExtension.Length] + : chosen; + var name = folder[(folder.LastIndexOf('/') + 1)..]; + var prefabPath = $"{folder}/{name}{PrefabExtension}"; + + var bake = new VoxelBake { Target = node.Uid, Path = prefabPath, Replace = true }; + var taken = new HashSet(StringComparer.OrdinalIgnoreCase) { name, "Model" }; + + bake.BasePath = $"{folder}/Model.tvox"; + CreateVoxelFile(bake.BasePath); + + // Every volume gets a model + void Walk(HierarchyElement parent) { + foreach (var child in parent.Children) { + if (child.Type is "toast::FillVolume" or "toast::VoxelMesh") { + var path = $"{folder}/{UniqueFileName(child.Name, taken)}.tvox"; + CreateVoxelFile(path); + bake.Pieces.Add(new VoxelBakePiece { Uid = child.Uid, Path = path }); + } + if (child.Type is "toast::VoxelGroup" or "toast::FillVolume" or "toast::VoxelMesh" or "toast::CarveVolume" + or "toast::PaintVolume") { + Walk(child); + } + } + } + + Walk(node); + + var prefabReal = ProjectContext.Resolve(prefabPath); + Directory.CreateDirectory(Path.GetDirectoryName(prefabReal)!); + File.WriteAllBytes(prefabReal, Array.Empty()); + MetaFile.Write(prefabReal, new MetaHeader { Uid = UidGenerator.Generate(), Type = "node" }); + + AssetDatabase.RebuildAssetDatabase(); + Events.Send(new ReloadAssetsManifest()); + Events.Send(bake); } - internal static void CreateVoxelAsset(string virtualPath) { + private const string PrefabExtension = ".tnode"; + + private static string UniqueFileName(string name, HashSet taken) { + var invalid = Path.GetInvalidFileNameChars(); + var clean = new string(name.Trim().Select(c => invalid.Contains(c) || c == ' ' ? '_' : c).ToArray()); + if (clean.Length == 0) clean = "Piece"; + var candidate = clean; + for (var i = 2; !taken.Add(candidate); i++) candidate = $"{clean}_{i}"; + return candidate; + } + + private static void CreateVoxelFile(string virtualPath) { var realPath = ProjectContext.Resolve(virtualPath); Directory.CreateDirectory(Path.GetDirectoryName(realPath)!); File.WriteAllBytes(realPath, Array.Empty()); MetaFile.Write(realPath, new MetaHeader { Uid = UidGenerator.Generate(), Type = "voxel_model" }); + } + + internal static void CreateVoxelAsset(string virtualPath) { + CreateVoxelFile(virtualPath); AssetDatabase.RebuildAssetDatabase(); Events.Send(new ReloadAssetsManifest()); } diff --git a/engine/src/toast/physics/raycast.cpp b/engine/src/toast/physics/raycast.cpp index 696242bc9..34ee42feb 100644 --- a/engine/src/toast/physics/raycast.cpp +++ b/engine/src/toast/physics/raycast.cpp @@ -394,6 +394,11 @@ auto Simulator::bodyFor(const toast::Node& node) -> BodyID { return rigidbody->bodyID(); } if (const toast::Box voxel = current.as(); voxel.exists()) { + for (const toast::VoxelPart& part : voxel->parts()) { + if (part.body != BodyID {}) { + return part.body; + } + } return voxel->bodyID(); } } diff --git a/engine/src/toast/physics/simulator.cpp b/engine/src/toast/physics/simulator.cpp index 7f8b73031..05bccea5b 100644 --- a/engine/src/toast/physics/simulator.cpp +++ b/engine/src/toast/physics/simulator.cpp @@ -460,48 +460,71 @@ void Simulator::registerVoxelNode(toast::VoxelNode& node) { node.syncWorldTransform(); const BodyType body_type = bodyTypeOf(node.simulation_type); const bool dynamic_body = body_type == BodyType::dynamic_body; - const BodyID body = instance->createBody( - BodyDescriptor { - .type = body_type, - .allow_sleep = node.allow_sleep, - .position = node.world_position, - .rotation = node.world_rotation, - .mass = 1.0f, // calculated later - .gravity_scale = node.gravity_scale, - .linear_damping = node.linear_damping, - .angular_damping = node.angular_damping, - .drag_scale = node.drag_scale, - .layers = {.included = node.included, .excluded = node.excluded}, - } - ); - if (not instance->valid(body)) { - return; - } - const ShapeID shape = instance->createVoxelShape(body, node); - if (not instance->valid(shape)) { - instance->destroyBody(body); - return; - } - if (Shape* stored_shape = instance->tryGetShape(shape)) { - stored_shape->material = material; - } + const auto add_body = [&](toast::VoxelPart* part) { + const BodyID body = instance->createBody( + BodyDescriptor { + .type = body_type, + .allow_sleep = node.allow_sleep, + .position = node.world_position, + .rotation = node.world_rotation, + .mass = 1.0f, // calculated later + .gravity_scale = node.gravity_scale, + .linear_damping = node.linear_damping, + .angular_damping = node.angular_damping, + .drag_scale = node.drag_scale, + .layers = {.included = node.included, .excluded = node.excluded}, + } + ); + if (not instance->valid(body)) { + return; + } - node.assignBody(body); - node.assignShape(shape); - setBodyEnabled(body, node.enabled()); - setShapeEnabled(shape, node.enabled()); - instance->m_voxel_bindings.push_back({ - .body = body, - .shape = shape, - .node = node.box().as(), - .source_revision = node.revision(), - .source_model = node.getModel().uid().data(), - .source_palette = node.paletteUid(), - }); + const ShapeID shape = instance->createVoxelShape(body, node, part); + if (not instance->valid(shape)) { + instance->destroyBody(body); + return; + } + if (Shape* stored_shape = instance->tryGetShape(shape)) { + stored_shape->material = material; + } - if (dynamic_body) { - instance->rebuildMassProperties(body); + if (part != nullptr) { + part->body = body; + part->shape = shape; + part->has_pose = false; + } else { + node.assignBody(body); + node.assignShape(shape); + } + setBodyEnabled(body, node.enabled()); + setShapeEnabled(shape, node.enabled()); + instance->m_voxel_bindings.push_back({ + .body = body, + .shape = shape, + .node = node.box().as(), + .is_part = part != nullptr, + .part_key = part != nullptr ? part->key : 0, + .source_revision = part != nullptr ? part->revision : node.revision(), + .source_model = node.getModel().uid().data(), + .source_palette = node.paletteUid(), + }); + + if (dynamic_body) { + instance->rebuildMassProperties(body); + } + }; + + if (node.usesParts()) { + // Every part is its own body + // They won't fall apart unless they are dynamic so it's fine (mostly) (i think) + for (toast::VoxelPart& part : node.m_parts) { + if (part.volume != nullptr) { + add_body(&part); + } + } + } else { + add_body(nullptr); } } @@ -539,6 +562,12 @@ void Simulator::unregisterVoxelNode(toast::VoxelNode& node) { std::scoped_lock voxel_lock {voxelDataMutex()}; node.m_retired_volumes.clear(); + for (toast::VoxelPart& part : node.m_parts) { + part.body = {}; + part.shape = {}; + part.has_pose = false; + part.retired.clear(); + } } void Simulator::registerTrigger(Trigger& node) { @@ -781,9 +810,7 @@ void Simulator::clearFragmentFromSource( ++data.surface_revision; if (data.solid_voxel_count == 0) { - if (toast::VoxelNode* node = voxelNodeFor(shape_id)) { - node->m_pending_events.emptied = true; - } + noteVoxelShapeEmptied(shape_id); } auto* shape = tryGetShape(shape_id); @@ -1773,19 +1800,31 @@ void Simulator::syncEnabledState() { body->enabled = binding.node->enabled(); + toast::VoxelPart* part = partOf(binding); + if (binding.is_part && part == nullptr) { + continue; + } + const uint32_t current_revision = part != nullptr ? part->revision : binding.node->revision(); const uint64_t model = binding.node->getModel().uid().data(); const uint64_t palette = binding.node->paletteUid(); - if (binding.source_revision != binding.node->revision() || binding.source_model != model || - binding.source_palette != palette) { + if (binding.source_revision != current_revision || binding.source_model != model || binding.source_palette != palette) { destroyShape(binding.shape); - binding.shape = createVoxelShape(binding.body, *binding.node); - binding.node->assignShape(binding.shape); - binding.source_revision = binding.node->revision(); + binding.shape = createVoxelShape(binding.body, *binding.node, part); + if (part != nullptr) { + part->shape = binding.shape; + } else { + binding.node->assignShape(binding.shape); + } + binding.source_revision = current_revision; binding.source_model = model; binding.source_palette = palette; { std::scoped_lock voxel_lock {voxelDataMutex()}; - binding.node->m_retired_volumes.clear(); + if (part != nullptr) { + part->retired.clear(); + } else { + binding.node->m_retired_volumes.clear(); + } } if (body->type == BodyType::dynamic_body) { rebuildMassProperties(binding.body); @@ -2144,21 +2183,79 @@ auto Simulator::publishVoxelTransform(VoxelNodeBinding& binding) -> bool { return false; } + toast::VoxelPart* part = partOf(binding); + if (binding.is_part && part == nullptr) { + return true; + } + if (body->type == BodyType::dynamic_body) { - if (body->enabled) { - binding.node->applyPhysicsTransform(body->position, body->rotation); + if (part == nullptr) { + if (body->enabled) { + binding.node->applyPhysicsTransform(body->position, body->rotation); + } + binding.node->publishPhysicsState(body->awake, body->linear_velocity, body->angular_velocity); + } else { + // The parts fall on their own + if (body->enabled) { + part->has_pose = true; + part->pose_position = body->position; + part->pose_rotation = body->rotation; + } + if (binding.node->m_parts.front().key == part->key) { + binding.node->publishPhysicsState(body->awake, body->linear_velocity, body->angular_velocity); + } } - binding.node->publishPhysicsState(body->awake, body->linear_velocity, body->angular_velocity); } if (const Shape* shape = tryGetShape(binding.shape); shape != nullptr && shape->type == ShapeType::voxel) { if (const VoxelShapeData* voxel_data = tryGetVoxelData(shape->voxel.data); voxel_data != nullptr) { - binding.node->mass = voxel::resolveMass(voxel_data->moments); + const float mass = voxel::resolveMass(voxel_data->moments); + if (part == nullptr) { + binding.node->mass = mass; + } else { + part->mass = mass; + float total = 0.0f; + for (const toast::VoxelPart& other : binding.node->m_parts) { + total += other.mass; + } + binding.node->mass = total; + } } } return true; } +auto Simulator::partOf(const VoxelNodeBinding& binding) -> toast::VoxelPart* { + if (not binding.is_part || not binding.node.exists()) { + return nullptr; + } + auto& parts = const_cast(*binding.node).m_parts; + const auto found = std::ranges::find(parts, binding.part_key, &toast::VoxelPart::key); + return found != parts.end() ? &*found : nullptr; +} + +void Simulator::recordVoxelDamage(ShapeID shape, uint32_t count, glm::vec3 index_sum) { + const auto binding = std::ranges::find(m_voxel_bindings, shape, &VoxelNodeBinding::shape); + if (binding == m_voxel_bindings.end() || not binding->node.exists()) { + return; + } + const toast::VoxelPart* part = partOf(*binding); + binding->node->recordDamage(count, index_sum, part != nullptr ? part->origin : binding->node->voxelOrigin()); +} + +void Simulator::noteVoxelShapeEmptied(ShapeID shape) { + toast::VoxelNode* node = voxelNodeFor(shape); + if (node == nullptr) { + return; + } + for (const toast::VoxelPart& part : node->m_parts) { + if (part.volume != nullptr && part.volume->solidVoxelCount() > 0) { + return; + } + } + node->m_pending_events.emptied = true; +} + namespace { inline constexpr uint64_t k_max_render_record_bricks = 1u << 22u; } @@ -2270,6 +2367,10 @@ auto Simulator::renderPoseDelta(const toast::VoxelNode& node) -> glm::mat4 { return renderPoseDelta(node.bodyID()); } +auto Simulator::renderPoseDelta(const toast::VoxelPart& part) -> glm::mat4 { + return renderPoseDelta(part.body); +} + auto Simulator::renderPoseDeltaFor(const toast::Node& node) -> glm::mat4 { if (instance == nullptr) { return {1.0f}; @@ -2305,7 +2406,7 @@ void Simulator::rebuildRenderPoseLookup() { } } for (const VoxelNodeBinding& binding : m_voxel_bindings) { - if (binding.node.exists()) { + if (binding.node.exists() && not binding.is_part) { add_driver(*binding.node, binding.body); } } @@ -3661,7 +3762,7 @@ void Simulator::checkTriggers() { } } -auto Simulator::createVoxelShape(BodyID owner, toast::VoxelNode& node) -> ShapeID { +auto Simulator::createVoxelShape(BodyID owner, toast::VoxelNode& node, toast::VoxelPart* part) -> ShapeID { const voxel::Palette* palette = node.resolvedPalette(); if (palette == nullptr) { palette = &voxel::defaultPalette(); @@ -3684,24 +3785,26 @@ auto Simulator::createVoxelShape(BodyID owner, toast::VoxelNode& node) -> ShapeI voxel::Volume* volume = nullptr; { std::scoped_lock voxel_lock {voxelDataMutex()}; - volume = node.volume(); + volume = part != nullptr ? part->volume.get() : node.volume(); } if (volume == nullptr) { TOAST_WARN("Physics", "Voxel node '{}' has no instantiated volume to collide against", node.name()); return {}; } - const VoxelShape voxel_shape {.local_center = glm::vec3(node.voxelOrigin()) * voxel::k_voxel_size}; + const glm::ivec3 origin = part != nullptr ? part->origin : node.voxelOrigin(); + const VoxelShape voxel_shape {.local_center = glm::vec3(origin) * voxel::k_voxel_size}; const ShapeID shape = createVoxelShape(owner, voxel_shape, *volume, *palette, *materials); const Shape* stored_shape = tryGetShape(shape); if (stored_shape != nullptr) { if (VoxelShapeData* data = tryGetVoxelData(stored_shape->voxel.data)) { - data->source_revision = node.revision(); + data->source_revision = part != nullptr ? part->revision : node.revision(); data->palette_uid = node.paletteUid(); - if (node.m_split_pending) { + bool& split_pending = part != nullptr ? part->split_pending : node.m_split_pending; + if (split_pending) { data->connectivity_dirty = true; - node.m_split_pending = false; + split_pending = false; } } } @@ -4424,11 +4527,17 @@ void Simulator::applyDamageCommand(const DamageCommand& c) { if (binding.shape != c.shape || not binding.node.exists()) { continue; } - ++binding.node->m_revision; - binding.source_revision = binding.node->revision(); - binding.node->recordDamage(removed, removed_sum); + if (toast::VoxelPart* part = partOf(binding)) { + ++part->revision; + binding.source_revision = part->revision; + binding.node->recordDamage(removed, removed_sum, part->origin); + } else { + ++binding.node->m_revision; + binding.source_revision = binding.node->revision(); + binding.node->recordDamage(removed, removed_sum); + } if (data->solid_voxel_count == 0) { - binding.node->m_pending_events.emptied = true; + noteVoxelShapeEmptied(c.shape); } break; } @@ -5029,8 +5138,8 @@ auto Simulator::explodeShape(ShapeID shape_id, const ExplosionDamage& explosion) return solid_before; } data->connectivity_dirty = true; - if (toast::VoxelNode* node = voxelNodeFor(shape_id); node != nullptr && not debris_voxels.empty()) { - node->recordDamage(static_cast(debris_voxels.size()), debris_index_sum); + if (not debris_voxels.empty()) { + recordVoxelDamage(shape_id, static_cast(debris_voxels.size()), debris_index_sum); } unlockSleep(shape->owner); wakeBodiesInBounds(bounds); @@ -5142,8 +5251,8 @@ auto Simulator::breakPieces( data->connectivity_dirty = true; const uint32_t moved = solid_before - std::min(solid_before, data->solid_voxel_count); - if (toast::VoxelNode* node = voxelNodeFor(shape_id); node != nullptr && moved > 0) { - node->recordDamage(moved, picked.index_sum); + if (moved > 0) { + recordVoxelDamage(shape_id, moved, picked.index_sum); } unlockSleep(shape->owner); diff --git a/engine/src/toast/physics/simulator.hpp b/engine/src/toast/physics/simulator.hpp index a56a4c0e9..53792628b 100644 --- a/engine/src/toast/physics/simulator.hpp +++ b/engine/src/toast/physics/simulator.hpp @@ -248,6 +248,8 @@ class TOAST_API Simulator { static auto renderPoseDelta(BodyID id) -> glm::mat4; [[nodiscard]] static auto renderPoseDelta(const toast::VoxelNode& node) -> glm::mat4; + [[nodiscard]] + static auto renderPoseDelta(const toast::VoxelPart& part) -> glm::mat4; /// Delta of the dynamic body a node sits below and identity when it follows none [[nodiscard]] @@ -365,6 +367,8 @@ class TOAST_API Simulator { BodyID body; ShapeID shape; toast::Box node; + bool is_part = false; + uint64_t part_key = 0; uint32_t source_revision = 0; uint64_t source_model = 0; uint64_t source_palette = 0; @@ -462,7 +466,7 @@ class TOAST_API Simulator { const voxel::MaterialLibrary& materials ) -> ShapeID; [[nodiscard]] - auto createVoxelShape(BodyID owner, toast::VoxelNode& node) -> ShapeID; + auto createVoxelShape(BodyID owner, toast::VoxelNode& node, toast::VoxelPart* part) -> ShapeID; void destroyShape(ShapeID shape); [[nodiscard]] @@ -620,6 +624,12 @@ class TOAST_API Simulator { [[nodiscard]] auto voxelNodeFor(ShapeID shape) -> toast::VoxelNode*; + [[nodiscard]] + static auto partOf(const VoxelNodeBinding& binding) -> toast::VoxelPart*; + + void recordVoxelDamage(ShapeID shape, uint32_t count, glm::vec3 index_sum); + void noteVoxelShapeEmptied(ShapeID shape); + void reapFragments(); void destroyFragmentRecord(BodyID id); void queuePendingFragments(std::span results); diff --git a/engine/src/toast/renderer/vulkan_renderer.cpp b/engine/src/toast/renderer/vulkan_renderer.cpp index 40a875dcd..8cc0aed01 100644 --- a/engine/src/toast/renderer/vulkan_renderer.cpp +++ b/engine/src/toast/renderer/vulkan_renderer.cpp @@ -4276,6 +4276,15 @@ void VulkanRenderer::buildVoxelProxies(RenderFrame& frame) { bool data_only = false; uint64_t highlight_id = 0; const toast::VoxelNode* node = nullptr; + uint64_t part_key = 0; + uint64_t outline_id = 0; + }; + + // chatty vibed this + const auto part_render_id = [](uint64_t node_rid, uint64_t part_key) { + uint64_t mixed = (node_rid * 0x9E3779B97F4A7C15ull) ^ ((part_key + 0x7F4A7C15ull) * 0xC2B2AE3D27D4EB4Full); + mixed ^= mixed >> 29; + return (mixed & 0x3FFFFFFFFFFFFFFFull) | (uint64_t {1} << 62); }; // Highlights share the voxel grid of their node so the shader looks up the same voxel @@ -4297,6 +4306,43 @@ void VulkanRenderer::buildVoxelProxies(RenderFrame& frame) { continue; } + if (node->usesParts()) { + const voxel::Palette* parts_palette = node->resolvedPalette(); + if (parts_palette == nullptr) { + parts_palette = &defaultVoxelPalette(); + } + for (const toast::VoxelPart& part : node->parts()) { + if (part.volume == nullptr) { + continue; + } + glm::mat4 pose = node->getWorldTransform(); + if (part.has_pose) { + pose = glm::translate(glm::mat4(1.0f), part.pose_position) * glm::mat4_cast(part.pose_rotation); + } + const uint64_t id = part_render_id(node->rid(), part.key); + // clang-format off + gathered.push_back(Gathered { + .id = id, + .volume = part.volume.get(), + .palette = parts_palette, + .model = *owner_transform * physics::Simulator::renderPoseDelta(part) * pose * part.localTransform(), + .debug_name = std::string{node->name()}, + .node = node, + .part_key = part.key, + .outline_id = node->rid() + }); + key.push_back({ + .node_rid = id, + .revision = part.revision, + .content = part.volume->revision(), + .palette = parts_palette, + .palette_revision = parts_palette->revision + }); + // clang-format on + } + continue; + } + voxel::Volume* volume = node->volume(); if (volume == nullptr) { continue; @@ -4329,7 +4375,7 @@ void VulkanRenderer::buildVoxelProxies(RenderFrame& frame) { std::scoped_lock lock(m_voxel_proxy_mutex); const size_t drawn = gathered.size(); for (size_t i = 0; i < drawn; ++i) { - auto it = m_voxel_highlights.find(gathered[i].node); + auto it = m_voxel_highlights.find(VoxelHighlightKey {gathered[i].node, gathered[i].part_key}); if (gathered[i].node == nullptr || it == m_voxel_highlights.end() || it->second->brickDims() != gathered[i].volume->brickDims()) { continue; @@ -4570,7 +4616,7 @@ void VulkanRenderer::buildVoxelProxies(RenderFrame& frame) { .mirrored = glm::determinant(model) < 0.0f, .node_rid = node_rid, .highlight_record = highlight_record_of(entry.highlight_id), - .outlined = isOutlined(node_rid), + .outlined = isOutlined(entry.outline_id != 0 ? entry.outline_id : node_rid), } ); drawn_models.insert_or_assign(node_rid, model); diff --git a/engine/src/toast/renderer/vulkan_renderer.hpp b/engine/src/toast/renderer/vulkan_renderer.hpp index a971f1e0e..813dc83b0 100644 --- a/engine/src/toast/renderer/vulkan_renderer.hpp +++ b/engine/src/toast/renderer/vulkan_renderer.hpp @@ -1245,15 +1245,20 @@ class VulkanRenderer { * Tints the voxels of node that are solid in highlight * @note nullptr clears it */ - void setVoxelHighlight(const toast::VoxelNode* node, const voxel::Volume* highlight) { + void setVoxelHighlight(const toast::VoxelNode* node, const voxel::Volume* highlight, uint64_t part_key = 0) { std::scoped_lock lock(m_voxel_proxy_mutex); if (highlight == nullptr) { - m_voxel_highlights.erase(node); + m_voxel_highlights.erase(VoxelHighlightKey {node, part_key}); } else { - m_voxel_highlights[node] = highlight; + m_voxel_highlights[VoxelHighlightKey {node, part_key}] = highlight; } } + void clearVoxelHighlights(const toast::VoxelNode* node) { + std::scoped_lock lock(m_voxel_proxy_mutex); + std::erase_if(m_voxel_highlights, [node](const auto& entry) { return entry.first.node == node; }); + } + void setSecondaryOwner(const toast::INodeOwner* owner, const glm::mat4& transform, const toast::Node* hidden) { m_secondary_owner = owner; m_secondary_transform = transform; @@ -1583,7 +1588,21 @@ class VulkanRenderer { std::optional m_pending_post_settings; const toast::INodeOwner* m_render_owner_filter = nullptr; - std::unordered_map m_voxel_highlights; + + struct VoxelHighlightKey { + const toast::VoxelNode* node = nullptr; + uint64_t part = 0; + + auto operator==(const VoxelHighlightKey&) const noexcept -> bool = default; + }; + + struct VoxelHighlightKeyHash { + auto operator()(const VoxelHighlightKey& key) const noexcept -> size_t { + return std::hash {}(key.node) ^ (std::hash {}(key.part) * 0x9E3779B97F4A7C15ull); + } + }; + + std::unordered_map m_voxel_highlights; const toast::INodeOwner* m_secondary_owner = nullptr; glm::mat4 m_secondary_transform {1.0f}; const toast::Node* m_secondary_hidden = nullptr; diff --git a/engine/src/toast/voxel/nodes/procedural_voxel.cpp b/engine/src/toast/voxel/nodes/procedural_voxel.cpp index a0a2d9019..191677c8e 100644 --- a/engine/src/toast/voxel/nodes/procedural_voxel.cpp +++ b/engine/src/toast/voxel/nodes/procedural_voxel.cpp @@ -5,6 +5,7 @@ #include #include #include +#include #include #include #include @@ -196,12 +197,18 @@ struct ProceduralVoxel::ComposeInput { }; struct ProceduralVoxel::ComposeOutput { - std::unique_ptr volume; - std::unique_ptr highlight; - glm::ivec3 origin {0}; + struct Part { + uint64_t key = 0; + glm::ivec3 origin {0}; + std::unique_ptr volume; + std::unique_ptr highlight; + }; + + std::vector parts; std::vector projections; bool has_projections = false; bool pool_exhausted = false; + bool too_many_parts = false; }; namespace { @@ -381,6 +388,158 @@ auto brickFloor(glm::ivec3 voxel) noexcept -> glm::ivec3 { return {voxel.x >> 3, voxel.y >> 3, voxel.z >> 3}; } +constexpr uint64_t k_preview_key = ~uint64_t {0}; + +template +void visitSolid(const voxel::Volume& volume, glm::ivec3 brick, Visit&& visit) { + const voxel::BrickEntry entry = volume.entryAt(brick); + if (entry.tag() == voxel::BrickTag::empty) { + return; + } + const glm::ivec3 corner = brick * static_cast(voxel::k_brick_dim); + if (entry.tag() == voxel::BrickTag::uniform) { + const auto id = static_cast(entry.payload()); + for (int32_t z = 0; z < static_cast(voxel::k_brick_dim); ++z) { + for (int32_t y = 0; y < static_cast(voxel::k_brick_dim); ++y) { + for (int32_t x = 0; x < static_cast(voxel::k_brick_dim); ++x) { + visit(corner + glm::ivec3(x, y, z), id); + } + } + } + return; + } + const voxel::BrickOccupancy& occupancy = *volume.occupancyPointer(brick); + const uint8_t* materials = std::as_const(*volume.pool()).material(entry.payload()).data(); + for (uint32_t z = 0; z < voxel::k_brick_dim; ++z) { + uint64_t bits = occupancy[z]; + while (bits != 0) { + const auto bit = static_cast(std::countr_zero(bits)); + bits &= bits - 1; + visit( + corner + glm::ivec3(static_cast(bit & 7), static_cast(bit >> 3), static_cast(z)), + materials[(z * 64) + bit] + ); + } + } +} + +/** + * Splits a built shape into one volume per owner + */ +template +auto splitByOwner( + const voxel::Volume& shape, const voxel::Volume& owner, const voxel::Volume* highlight, glm::ivec3 origin, + const std::vector& keys, bool& pool_exhausted +) -> std::vector { + ZoneScopedN("voxel::SplitByOwner"); // NOLINT + + struct Extent { + glm::ivec3 min {std::numeric_limits::max()}; + glm::ivec3 max {std::numeric_limits::min()}; + bool any = false; + }; + + std::vector extents(keys.size()); + + // Every owner number in a brick + const auto owners_in = [&](glm::ivec3 brick) { + std::bitset<256> seen; + const voxel::BrickEntry entry = owner.entryAt(brick); + if (entry.tag() == voxel::BrickTag::uniform) { + seen.set(static_cast(entry.payload())); + } else if (entry.tag() != voxel::BrickTag::empty) { + const voxel::BrickOccupancy& occupancy = *owner.occupancyPointer(brick); + const uint8_t* materials = std::as_const(*owner.pool()).material(entry.payload()).data(); + for (uint32_t z = 0; z < voxel::k_brick_dim; ++z) { + uint64_t bits = occupancy[z]; + while (bits != 0) { + const auto bit = static_cast(std::countr_zero(bits)); + bits &= bits - 1; + seen.set(materials[(z * 64) + bit]); + } + } + } + seen.reset(0); + return seen; + }; + + std::vector>> bricks; + for (uint32_t index = 0; index < owner.brickCount(); ++index) { + const glm::ivec3 brick = owner.brickAtIndex(index); + if (owner.entryAt(brick).tag() == voxel::BrickTag::empty) { + continue; + } + const std::bitset<256> seen = owners_in(brick); + for (size_t id = 1; id <= keys.size(); ++id) { + if (seen.test(id)) { + Extent& extent = extents[id - 1]; + extent.min = glm::min(extent.min, brick); + extent.max = glm::max(extent.max, brick); + extent.any = true; + } + } + bricks.emplace_back(brick, seen); + } + + std::vector parts; + std::vector part_of(keys.size(), -1); + for (size_t id = 1; id <= keys.size(); ++id) { + const Extent& extent = extents[id - 1]; + if (!extent.any) { + continue; + } + Part part; + part.key = keys[id - 1]; + part.origin = origin + (extent.min * static_cast(voxel::k_brick_dim)); + part.volume = std::make_unique(voxel::proceduralBrickPool(), glm::uvec3(extent.max - extent.min + 1)); + part_of[id - 1] = static_cast(parts.size()); + parts.push_back(std::move(part)); + } + + for (const auto& [brick, seen] : bricks) { + if (seen.count() == 1) { + // Whole brick moves + size_t id = 1; + while (!seen.test(id)) { + ++id; + } + if (id <= keys.size()) { + const Extent& extent = extents[id - 1]; + parts[static_cast(part_of[id - 1])].volume->copyBrickFrom(brick - extent.min, shape, brick); + } + continue; + } + visitSolid(owner, brick, [&](glm::ivec3 cell, uint8_t id) { + if (id == 0 || id > keys.size()) { + return; + } + const Extent& extent = extents[id - 1]; + const voxel::Volume::VoxelWrite write = parts[static_cast(part_of[id - 1])].volume->setVoxel( + cell - (extent.min * static_cast(voxel::k_brick_dim)), shape.materialAt(cell) + ); + pool_exhausted = pool_exhausted || !write.changed; + }); + } + + if (highlight != nullptr) { + for (uint32_t index = 0; index < highlight->brickCount(); ++index) { + visitSolid(*highlight, highlight->brickAtIndex(index), [&](glm::ivec3 cell, uint8_t tint) { + const uint8_t id = owner.materialAt(cell); + if (id == 0 || id > keys.size() || part_of[id - 1] < 0) { + return; + } + Part& part = parts[static_cast(part_of[id - 1])]; + if (part.highlight == nullptr) { + part.highlight = std::make_unique(voxel::proceduralBrickPool(), part.volume->brickDims()); + } + const Extent& extent = extents[id - 1]; + part.highlight->setVoxel(cell - (extent.min * static_cast(voxel::k_brick_dim)), tint); + }); + } + } + return parts; +} + } void ProceduralVoxel::rebuild() { @@ -530,6 +689,7 @@ auto ProceduralVoxel::land(const std::vector& pieces, bool wait) -> s .kind = entry.piece->kind(), .mode = entry.piece->writeMode(), .match_id = entry.piece->matchId(), + .key = partKeyOf(*entry.piece), }); } return landings; @@ -590,33 +750,14 @@ void ProceduralVoxel::buildShape() { std::vector pieces; collect(*this, glm::mat4(1.0f), pieces); - std::vector landings = land(pieces, true); - - voxel::EditBounds grows {}; - for (const Landing& landing : landings) { - if (landing.kind == PieceKind::fill) { - grows = voxel::boundsUnion(grows, voxel::placedBounds(landing.grid->volume->brickDims(), landing.placement)); - } - } - - voxel::Volume* target = grows.empty() ? volume() : ensureContains(grows); - m_layout = layoutOf(landings); - if (target != nullptr) { - for (const Landing& landing : landings) { - commitEdit(apply(*target, landing, voxelOrigin()), "build"); - } - - // Buckets paint the finished shape so they never depend on the order of the pieces - std::vector buckets; - collectBuckets(*this, glm::mat4(1.0f), buckets); - for (const BucketFill& fill : buckets) { - commitEdit(voxel::floodFill(*target, toIndex(fill.seed), fill.bucket->colorId()), "bucket"); - } - } + const std::shared_ptr input = makeComposeInput(pieces, true); + m_pending_layout = layoutOf(input->landings); + const std::shared_ptr output = compose(*input); + applyComposed(*output); // In game the shape is only built once and destruction owns it after that if (!m_keep_grids) { - for (const Landing& landing : landings) { + for (const Landing& landing : input->landings) { landing.piece->releaseGrid(); } } @@ -668,13 +809,30 @@ void ProceduralVoxel::tickAsyncBuild() { } } +auto ProceduralVoxel::makeComposeInput(const std::vector& pieces, bool wait) -> std::shared_ptr { + auto input = std::make_shared(); + input->landings = land(pieces, wait); + + std::vector buckets; + collectBuckets(*this, glm::mat4(1.0f), buckets); + for (const BucketFill& fill : buckets) { + input->buckets.emplace_back(fill.seed, fill.bucket->colorId()); + } + + if (const assets::VoxelModel* model = resolvedModel()) { + if (std::optional instance = model->instantiate(voxel::proceduralBrickPool())) { + input->base = std::make_unique(std::move(*instance)); + } + } + return input; +} + void ProceduralVoxel::launchCompose(const std::vector& pieces) { ZoneScopedN("voxel::LaunchCompose"); // NOLINT m_compose_wanted = false; const renderer::EditorOverlays& overlays = renderer::editorOverlays(); - auto input = std::make_shared(); - input->landings = land(pieces, false); + std::shared_ptr input = makeComposeInput(pieces, false); m_pending_layout = layoutOf(input->landings); // The selected piece gets tinted where it still shows @@ -686,12 +844,6 @@ void ProceduralVoxel::launchCompose(const std::vector& pieces) { } } - std::vector buckets; - collectBuckets(*this, glm::mat4(1.0f), buckets); - for (const BucketFill& fill : buckets) { - input->buckets.emplace_back(fill.seed, fill.bucket->colorId()); - } - // The preview draws with the script the new volume will get if (VoxelPiece* tool = toolPiece()) { if (VoxelPiece::GridRef grid = tool->readyGrid()) { @@ -704,12 +856,6 @@ void ProceduralVoxel::launchCompose(const std::vector& pieces) { } } - if (const assets::VoxelModel* model = resolvedModel()) { - if (std::optional instance = model->instantiate(voxel::proceduralBrickPool())) { - input->base = std::make_unique(std::move(*instance)); - } - } - input->projections = overlays.projections; input->palette = resolvedPalette() != nullptr ? *resolvedPalette() : voxel::defaultPalette(); m_pending_projections = input->projections; @@ -741,61 +887,119 @@ auto ProceduralVoxel::compose(const ComposeInput& input) -> std::shared_ptrorigin = first * static_cast(voxel::k_brick_dim); - out->volume = std::make_unique(voxel::proceduralBrickPool(), glm::uvec3(last - first + 1)); - voxel::Volume& target = *out->volume; + const glm::ivec3 origin = first * static_cast(voxel::k_brick_dim); + voxel::Volume target(voxel::proceduralBrickPool(), glm::uvec3(last - first + 1)); + + // Which piece every voxel belongs to + voxel::Volume owner(voxel::proceduralBrickPool(), target.brickDims()); + std::vector owner_keys; + const auto claim = [&](uint64_t key) -> uint8_t { + if (owner_keys.size() >= 255) { + out->too_many_parts = true; + return 255; + } + owner_keys.push_back(key); + return static_cast(owner_keys.size()); + }; + // Where the piece draws + const auto claim_table = [](const voxel::PaletteRemapTable& remap, uint8_t id) { + voxel::PaletteRemapTable table {}; + for (uint32_t i = 1; i < voxel::k_palette_size; ++i) { + table[i] = remap[i] != voxel::k_empty_palette_index ? id : voxel::k_empty_palette_index; + } + return table; + }; + // Painting only recolors so its not a volume + const auto claim_mode = [](voxel::WriteMode mode, uint8_t match_id) -> std::optional { + switch (mode) { + case voxel::WriteMode::replace: return voxel::WriteMode::replace; + case voxel::WriteMode::empty_only: return voxel::WriteMode::empty_only; + case voxel::WriteMode::match: + return match_id == voxel::k_empty_palette_index ? std::optional {voxel::WriteMode::empty_only} : std::nullopt; + case voxel::WriteMode::solid_only: return std::nullopt; + } + return std::nullopt; + }; voxel::EditResult result; if (input.base != nullptr) { - const voxel::LatticePlacement at_zero {.offset = -out->origin}; + const voxel::LatticePlacement at_zero {.offset = -origin}; result += voxel::stampVolume(target, *input.base, at_zero, voxel::identityRemap(), voxel::WriteMode::replace, 0); + const uint8_t id = claim(0); + result += + voxel::stampVolume(owner, *input.base, at_zero, claim_table(voxel::identityRemap(), id), voxel::WriteMode::replace, 0); } for (const Landing& landing : input.landings) { - result += apply(target, landing, out->origin); + result += apply(target, landing, origin); + + voxel::LatticePlacement indexed = landing.placement; + indexed.offset -= origin; + const voxel::Volume& grid = *landing.grid->volume; + switch (landing.kind) { + case PieceKind::fill: { + const uint8_t id = claim(landing.key); + if (const std::optional mode = claim_mode(landing.mode, landing.match_id)) { + result += voxel::stampVolume(owner, grid, indexed, claim_table(landing.remap, id), *mode, 0); + } + break; + } + case PieceKind::carve: result += voxel::carveVolume(owner, grid, indexed); break; + case PieceKind::paint: break; + } } for (const auto& [seed, id] : input.buckets) { - result += voxel::floodFill(target, seed - out->origin, id); + result += voxel::floodFill(target, seed - origin, id); } if (input.preview.has_value()) { const voxel::Volume& grid = *input.preview->grid->volume; - const voxel::LatticePlacement placement {.offset = input.preview->box.min - out->origin}; + const voxel::LatticePlacement placement {.offset = input.preview->box.min - origin}; switch (input.preview->kind) { - case 0: result += voxel::stampVolume(target, grid, placement, voxel::identityRemap(), input.preview->mode, 0); break; - case 1: result += voxel::carveVolume(target, grid, placement); break; + case 0: { + result += voxel::stampVolume(target, grid, placement, voxel::identityRemap(), input.preview->mode, 0); + const uint8_t id = claim(k_preview_key); + if (const std::optional mode = claim_mode(input.preview->mode, 0)) { + result += voxel::stampVolume(owner, grid, placement, claim_table(voxel::identityRemap(), id), *mode, 0); + } + break; + } + case 1: + result += voxel::carveVolume(target, grid, placement); + result += voxel::carveVolume(owner, grid, placement); + break; default: result += voxel::stampVolume(target, grid, placement, voxel::identityRemap(), voxel::WriteMode::solid_only, 0); break; } } + std::unique_ptr highlight; if (input.highlight.has_value()) { ZoneScopedN("voxel::ProceduralHighlight"); // NOLINT const Landing& selected = input.landings[*input.highlight]; - out->highlight = std::make_unique(voxel::proceduralBrickPool(), target.brickDims()); - voxel::Volume& highlight = *out->highlight; + highlight = std::make_unique(voxel::proceduralBrickPool(), target.brickDims()); voxel::LatticePlacement indexed = selected.placement; - indexed.offset -= out->origin; - voxel::stampVolume(highlight, *selected.grid->volume, indexed, voxel::identityRemap(), voxel::WriteMode::replace, 0); + indexed.offset -= origin; + voxel::stampVolume(*highlight, *selected.grid->volume, indexed, voxel::identityRemap(), voxel::WriteMode::replace, 0); // Only where the finished shape still has voxels - for (uint32_t index = 0; index < highlight.brickCount(); ++index) { - const glm::ivec3 brick = highlight.brickAtIndex(index); - const voxel::BrickOccupancy* mine = highlight.occupancyPointer(brick); + for (uint32_t index = 0; index < highlight->brickCount(); ++index) { + const glm::ivec3 brick = highlight->brickAtIndex(index); + const voxel::BrickOccupancy* mine = highlight->occupancyPointer(brick); if (mine == nullptr) { continue; } const voxel::BrickOccupancy* shown = target.occupancyPointer(brick); if (shown == nullptr) { - highlight.setBrickUniform(brick, voxel::k_empty_palette_index); + highlight->setBrickUniform(brick, voxel::k_empty_palette_index); continue; } const voxel::BrickOccupancy hidden = *mine & ~*shown; if (voxel::isEmpty(hidden)) { continue; } - const std::optional write = highlight.beginBrickWrite(brick); + const std::optional write = highlight->beginBrickWrite(brick); if (!write.has_value()) { continue; } @@ -808,17 +1012,41 @@ auto ProceduralVoxel::compose(const ComposeInput& input) -> std::shared_ptroccupancy)[z] &= ~hidden[z]; } - highlight.finishBrickWrite(brick, true); + highlight->finishBrickWrite(brick, true); } } if (input.projections) { - projectShape(target, out->origin, input.palette, out->projections); + projectShape(target, origin, input.palette, out->projections); } - out->pool_exhausted = result.pool_exhausted; + + bool split_exhausted = false; + out->parts = splitByOwner(target, owner, highlight.get(), origin, owner_keys, split_exhausted); + out->pool_exhausted = result.pool_exhausted || split_exhausted; return out; } +auto ProceduralVoxel::growPart(VoxelPart& part, const voxel::EditBounds& bounds) -> bool { + constexpr int32_t k_max_bricks_per_axis = 512; + constexpr int64_t k_max_bricks = int64_t {1} << 21; + + const glm::ivec3 first = brickFloor(bounds.min); + const glm::ivec3 dims = brickFloor(bounds.max) - first + 1; + if (glm::any(glm::greaterThan(dims, glm::ivec3(k_max_bricks_per_axis))) || + static_cast(dims.x) * dims.y * dims.z > k_max_bricks) { + return false; + } + + // Physics keeps reading the old volume until it builds the new one + if (part.volume != nullptr && part.body != physics::BodyID {}) { + part.retired.push_back(std::move(part.volume)); + } + part.volume = std::make_unique(voxel::runtimeBrickPool(), glm::uvec3(dims)); + part.origin = first * static_cast(voxel::k_brick_dim); + ++part.revision; + return true; +} + void ProceduralVoxel::applyComposed(ComposeOutput& output) { ZoneScopedN("voxel::ApplyProcedural"); // NOLINT if (output.pool_exhausted) { @@ -829,101 +1057,151 @@ void ProceduralVoxel::applyComposed(ComposeOutput& output) { voxel::k_procedural_brick_capacity ); } + if (output.too_many_parts) { + TOAST_WARN("Voxel", "'{}' has more than 255 solid pieces, the last ones share one body", name()); + } renderer::VulkanRenderer* renderer = renderer::VulkanRenderer::instance; + constexpr auto k_dim = static_cast(voxel::k_brick_dim); - voxel::Volume* target = nullptr; + bool parts_changed = false; { std::scoped_lock lock(voxel::runtimePoolMutex()); - target = volume(); - if (output.volume != nullptr) { - const glm::ivec3 wanted_min = output.origin; - const glm::ivec3 wanted_max = output.origin + glm::ivec3(output.volume->voxelDims()) - 1; - const auto fits = [&](const voxel::Volume* current) { - return current != nullptr && glm::all(glm::greaterThanEqual(wanted_min, voxelOrigin())) && - glm::all(glm::lessThan(wanted_max, voxelOrigin() + glm::ivec3(current->voxelDims()))); - }; - if (!fits(target)) { - // Growing makes a new volume the renderer uploads whole - const glm::ivec3 slack = - participatesIn(NodeOwnerParticipation::gameplay_tick) - ? glm::ivec3(0) - : glm::max(glm::ivec3(2 * static_cast(voxel::k_brick_dim)), (wanted_max - wanted_min + 1) / 4); - voxel::Volume* grown = replaceVolume({wanted_min - slack, wanted_max + slack}); - if (grown == nullptr) { - grown = replaceVolume({wanted_min, wanted_max}); - } - if (grown != nullptr) { - target = grown; - } else { - TOAST_WARN("Voxel", "'{}' is too big for one volume, it is clipped to the current one", name()); + + for (size_t i = m_parts.size(); i-- > 0;) { + VoxelPart& part = m_parts[i]; + if (std::ranges::any_of(output.parts, [&](const ComposeOutput::Part& incoming) { return incoming.key == part.key; })) { + continue; + } + if (renderer != nullptr) { + renderer->setVoxelHighlight(this, nullptr, part.key); + } + + if (part.body != physics::BodyID {}) { + for (std::unique_ptr& retired : part.retired) { + m_retired_volumes.push_back(std::move(retired)); } + m_retired_volumes.push_back(std::move(part.volume)); } + m_parts.erase(m_parts.begin() + static_cast(i)); + parts_changed = true; } - if (target != nullptr) { - if (m_highlight != nullptr && m_highlight->brickDims() != target->brickDims()) { - if (renderer != nullptr) { - renderer->setVoxelHighlight(this, nullptr); - } - m_highlight.reset(); + for (ComposeOutput::Part& incoming : output.parts) { + auto found = std::ranges::find(m_parts, incoming.key, &VoxelPart::key); + if (found == m_parts.end()) { + m_parts.emplace_back(); + m_parts.back().key = incoming.key; + found = m_parts.end() - 1; + parts_changed = true; } - if (m_highlight == nullptr && output.highlight != nullptr) { - m_highlight = std::make_unique(voxel::runtimeBrickPool(), target->brickDims()); + VoxelPart& part = *found; + + const voxel::Volume& source = *incoming.volume; + const glm::ivec3 wanted_min = incoming.origin; + const glm::ivec3 wanted_max = incoming.origin + glm::ivec3(source.voxelDims()) - 1; + const bool fits = part.volume != nullptr && glm::all(glm::greaterThanEqual(wanted_min, part.origin)) && + glm::all(glm::lessThan(wanted_max, part.origin + glm::ivec3(part.volume->voxelDims()))); + if (!fits) { + // Growing makes a new volume the renderer uploads whole + const glm::ivec3 slack = participatesIn(NodeOwnerParticipation::gameplay_tick) + ? glm::ivec3(0) + : glm::max(glm::ivec3(2 * k_dim), (wanted_max - wanted_min + 1) / 4); + bool grown = growPart(part, {wanted_min - slack, wanted_max + slack}); + if (!grown) { + grown = growPart(part, {wanted_min, wanted_max}); + } + if (!grown && part.volume == nullptr) { + TOAST_WARN("Voxel", "'{}' has a piece that is too big for one volume, it is left out", name()); + m_parts.erase(found); + continue; + } + if (!grown) { + TOAST_WARN("Voxel", "'{}' has a piece that is too big for one volume, it is clipped to the current one", name()); + } } // Both origins sit on bricks - const glm::ivec3 shift = (voxelOrigin() - output.origin) / static_cast(voxel::k_brick_dim); + const glm::ivec3 shift = (part.origin - incoming.origin) / k_dim; bool changed = false; - for (uint32_t index = 0; index < target->brickCount(); ++index) { - const glm::ivec3 brick = target->brickAtIndex(index); - const glm::ivec3 source = brick + shift; - const bool inside = output.volume != nullptr && output.volume->containsBrick(source); - if (inside) { - changed = target->copyBrickFrom(brick, *output.volume, source) || changed; - } else if (target->entryAt(brick).tag() != voxel::BrickTag::empty) { - target->setBrickUniform(brick, voxel::k_empty_palette_index); + for (uint32_t index = 0; index < part.volume->brickCount(); ++index) { + const glm::ivec3 brick = part.volume->brickAtIndex(index); + const glm::ivec3 from = brick + shift; + if (source.containsBrick(from)) { + changed = part.volume->copyBrickFrom(brick, source, from) || changed; + } else if (part.volume->entryAt(brick).tag() != voxel::BrickTag::empty) { + part.volume->setBrickUniform(brick, voxel::k_empty_palette_index); changed = true; } - if (m_highlight != nullptr) { - if (inside && output.highlight != nullptr) { - m_highlight->copyBrickFrom(brick, *output.highlight, source); - } else if (m_highlight->entryAt(brick).tag() != voxel::BrickTag::empty) { - m_highlight->setBrickUniform(brick, voxel::k_empty_palette_index); - } - } } if (changed) { - ++m_revision; - m_split_pending = true; + ++part.revision; + part.split_pending = true; } - } else if (m_highlight != nullptr) { - if (renderer != nullptr) { - renderer->setVoxelHighlight(this, nullptr); + + // The tint has the same bricks as the volume + if (part.highlight != nullptr && + (incoming.highlight == nullptr || part.highlight->brickDims() != part.volume->brickDims())) { + if (renderer != nullptr) { + renderer->setVoxelHighlight(this, nullptr, part.key); + } + part.highlight.reset(); + } + if (incoming.highlight != nullptr) { + if (part.highlight == nullptr) { + part.highlight = std::make_unique(voxel::runtimeBrickPool(), part.volume->brickDims()); + } + for (uint32_t index = 0; index < part.highlight->brickCount(); ++index) { + const glm::ivec3 brick = part.highlight->brickAtIndex(index); + const glm::ivec3 from = brick + shift; + if (incoming.highlight->containsBrick(from)) { + part.highlight->copyBrickFrom(brick, *incoming.highlight, from); + } else if (part.highlight->entryAt(brick).tag() != voxel::BrickTag::empty) { + part.highlight->setBrickUniform(brick, voxel::k_empty_palette_index); + } + } + if (renderer != nullptr) { + renderer->setVoxelHighlight(this, part.highlight.get(), part.key); + } } - m_highlight.reset(); } - } - if (renderer != nullptr && m_highlight != nullptr) { - renderer->setVoxelHighlight(this, m_highlight.get()); + + if (parts_changed) { + ++m_parts_revision; + } } - // Only the projections are kept for the layout // The bricks go back to the pool - output.volume.reset(); - output.highlight.reset(); + output.parts.clear(); m_layout = std::move(m_pending_layout); m_pending_layout.clear(); + + // Bodies exist per part + // We need to build them agains + if (parts_changed) { + reregisterPhysics(); + } +} + +auto ProceduralVoxel::bakeParts() -> std::vector { + std::vector pieces; + collect(*this, glm::mat4(1.0f), pieces); + const std::shared_ptr input = makeComposeInput(pieces, true); + const std::shared_ptr output = compose(*input); + + std::vector out; + out.reserve(output->parts.size()); + for (ComposeOutput::Part& part : output->parts) { + out.push_back({.key = part.key, .origin = part.origin, .volume = std::move(part.volume)}); + } + return out; } auto ProceduralVoxel::pickPiece(glm::vec3 origin, glm::vec3 direction) -> VoxelPiece* { VoxelPiece* best = nullptr; float best_t = 1e6f; - if (const voxel::Volume* shape = volume()) { - if (const std::optional hit = - voxel::raycast(*shape, origin - glm::vec3(voxelOrigin()), direction, best_t)) { - best_t = hit->t; - best = pieceAt(hit->voxel + voxelOrigin()); - } + if (const std::optional hit = raycastVoxels(origin, direction, best_t)) { + best_t = hit->t; + best = pieceAt(hit->voxel); } for (const PieceLayout& layout : m_layout) { @@ -995,9 +1273,8 @@ auto ProceduralVoxel::pieceAt(glm::ivec3 voxel) -> VoxelPiece* { void ProceduralVoxel::end() { if (renderer::VulkanRenderer::instance != nullptr) { - renderer::VulkanRenderer::instance->setVoxelHighlight(this, nullptr); + renderer::VulkanRenderer::instance->clearVoxelHighlights(this); } - m_highlight.reset(); m_highlight_for = {}; // The compose only reads its own snapshot so it is left to finish on its own m_compose_job = {}; diff --git a/engine/src/toast/voxel/nodes/procedural_voxel.hpp b/engine/src/toast/voxel/nodes/procedural_voxel.hpp index ce93bea3f..f09744b89 100644 --- a/engine/src/toast/voxel/nodes/procedural_voxel.hpp +++ b/engine/src/toast/voxel/nodes/procedural_voxel.hpp @@ -58,7 +58,7 @@ class [[ToastNode, Color("Magenta")]] TOAST_API ProceduralVoxel : public VoxelNo [[Reflect, Button("Rebuild")]] void rebuild(); - /** Bakes the whole shape to a .tvox and replaces this node with a VoxelNode */ + /** Bakes the shape to a folder with a .tnode prefab and a .tvox model for every volume */ [[Reflect, Button("Bake"), EditorAction("voxel_editor.bake")]] void bakeVoxel() { } @@ -101,6 +101,26 @@ class [[ToastNode, Color("Magenta")]] TOAST_API ProceduralVoxel : public VoxelNo glm::ivec3 origin {0}; ///< Shape voxel of the volume's first voxel }; + [[nodiscard]] + static auto partKeyOf(const VoxelPiece& piece) noexcept -> uint64_t { + const uint64_t uid = piece.uid().data(); + return uid != 0 ? uid : (uint64_t {1} << 63) | piece.rid(); + } + + struct BakedPart { + uint64_t key = 0; + glm::ivec3 origin {0}; + std::unique_ptr volume; + }; + + [[nodiscard]] + auto bakeParts() -> std::vector; + + [[nodiscard]] + auto usesParts() const noexcept -> bool override { + return true; + } + /** * Builds only these pieces, in shape order, into a fresh volume * @note Collapse bakes it into a model, the shape itself is untouched @@ -135,6 +155,7 @@ class [[ToastNode, Color("Magenta")]] TOAST_API ProceduralVoxel : public VoxelNo PieceKind kind = PieceKind::fill; voxel::WriteMode mode = voxel::WriteMode::replace; uint8_t match_id = 0; + uint64_t key = 0; }; struct ComposeInput; @@ -174,9 +195,15 @@ class [[ToastNode, Color("Magenta")]] TOAST_API ProceduralVoxel : public VoxelNo /** Start building the piece async */ void launchCompose(const std::vector& pieces); - /** Copies the bricks of a finished compose */ + /** Copies the volumes of a finished compose into the parts */ void applyComposed(ComposeOutput& output); + /** Makes the volume of part cover bounds */ + auto growPart(VoxelPart& part, const voxel::EditBounds& bounds) -> bool; + + [[nodiscard]] + auto makeComposeInput(const std::vector& pieces, bool wait) -> std::shared_ptr; + [[nodiscard]] auto averageColor(const Landing& landing) -> glm::vec3; @@ -186,7 +213,6 @@ class [[ToastNode, Color("Magenta")]] TOAST_API ProceduralVoxel : public VoxelNo [[nodiscard]] auto toolPiece() -> VoxelPiece*; - std::unique_ptr m_highlight; UID m_highlight_for; std::vector m_layout; diff --git a/engine/src/toast/voxel/nodes/voxel_node.cpp b/engine/src/toast/voxel/nodes/voxel_node.cpp index b056fccbc..9d0d52c68 100644 --- a/engine/src/toast/voxel/nodes/voxel_node.cpp +++ b/engine/src/toast/voxel/nodes/voxel_node.cpp @@ -78,6 +78,23 @@ auto VoxelNode::resolvedModel() const -> const assets::VoxelModel* { } auto VoxelNode::localBoundingSphere() const -> glm::vec4 { + if (usesParts()) { + bool any = false; + glm::vec3 low {0.0f}; + glm::vec3 high {0.0f}; + for (const VoxelPart& part : m_parts) { + if (part.volume == nullptr) { + continue; + } + const glm::vec3 first = glm::vec3(part.origin) * voxel::k_voxel_size; + const glm::vec3 last = first + (glm::vec3(part.volume->brickDims()) * voxel::k_brick_size); + low = any ? glm::min(low, first) : first; + high = any ? glm::max(high, last) : last; + any = true; + } + return any ? glm::vec4((low + high) * 0.5f, glm::length(high - low) * 0.5f) : glm::vec4(0.0f); + } + glm::vec3 extent; if (m_volume != nullptr) { extent = glm::vec3(m_volume->brickDims()) * voxel::k_brick_size; @@ -129,7 +146,7 @@ auto VoxelNode::volume() -> voxel::Volume* { m_voxel_origin = glm::ivec3(0); m_edit_shape_pending = true; - if (model != nullptr) { + if (model != nullptr && !usesParts()) { std::optional instance = model->instantiate(voxel::runtimeBrickPool()); if (instance.has_value()) { m_volume = std::make_unique(std::move(*instance)); @@ -197,6 +214,11 @@ auto VoxelNode::resolvedMaterialLibrary() -> const voxel::MaterialLibrary* { } void VoxelNode::releaseVolume() { + if (!m_parts.empty()) { + std::scoped_lock lock(voxel::runtimePoolMutex()); + m_parts.clear(); + ++m_parts_revision; + } m_volume.reset(); m_retired_volumes.clear(); m_instanced_from = nullptr; @@ -204,6 +226,14 @@ void VoxelNode::releaseVolume() { m_volume_stale = true; } +void VoxelNode::reregisterPhysics() { + if (!m_registration_requested) { + return; + } + physics::Simulator::unregisterVoxelNode(*this); + physics::Simulator::registerVoxelNode(*this); +} + void VoxelNode::retireVolume() { if (m_volume != nullptr && physicsBound()) { m_retired_volumes.push_back(std::move(m_volume)); @@ -335,8 +365,12 @@ void VoxelNode::commitEdit(const voxel::EditResult& result, std::string_view ope } void VoxelNode::recordDamage(uint32_t count, glm::vec3 index_sum) { + recordDamage(count, index_sum, m_voxel_origin); +} + +void VoxelNode::recordDamage(uint32_t count, glm::vec3 index_sum, glm::ivec3 origin) { m_pending_events.damaged_voxels += count; - m_pending_events.damaged_sum += index_sum + (glm::vec3(m_voxel_origin) + 0.5f) * static_cast(count); + m_pending_events.damaged_sum += index_sum + (glm::vec3(origin) + 0.5f) * static_cast(count); } void VoxelNode::postPhysics() { @@ -361,6 +395,13 @@ void VoxelNode::postPhysics() { template void VoxelNode::edit(const voxel::EditBounds& local_bounds, bool grows, std::string_view operation, Kernel&& kernel) { + if (usesParts()) { + if (!m_warned_part_edit) { + m_warned_part_edit = true; + TOAST_WARN("Voxel", "'{}' is made of pieces, {} does nothing, draw in a piece instead", name(), operation); + } + return; + } voxel::Volume* target = grows ? ensureContains(local_bounds) : volume(); if (target == nullptr) { return; @@ -371,7 +412,83 @@ void VoxelNode::edit(const voxel::EditBounds& local_bounds, bool grows, std::str using _detail::toId; using _detail::toVoxel; +auto VoxelNode::ownsBody(physics::BodyID body) const noexcept -> bool { + if (body == physics::BodyID {}) { + return false; + } + return m_body == body || std::ranges::any_of(m_parts, [body](const VoxelPart& part) { return part.body == body; }); +} + +auto VoxelNode::isSolidAtVoxel(glm::ivec3 voxel) -> bool { + if (!usesParts()) { + const voxel::Volume* current = volume(); + return current != nullptr && current->isSolidAt(toIndex(voxel)); + } + return std::ranges::any_of(m_parts, [voxel](const VoxelPart& part) { + return part.volume != nullptr && part.volume->isSolidAt(voxel - part.origin); + }); +} + +auto VoxelNode::solidBounds() -> std::optional { + if (!usesParts()) { + const voxel::Volume* current = volume(); + if (current == nullptr) { + return std::nullopt; + } + const std::optional bounds = voxel::occupiedBounds(*current); + if (!bounds.has_value()) { + return std::nullopt; + } + return voxel::EditBounds {bounds->min + m_voxel_origin, bounds->max + m_voxel_origin}; + } + std::optional out; + for (const VoxelPart& part : m_parts) { + if (part.volume == nullptr) { + continue; + } + const std::optional bounds = voxel::occupiedBounds(*part.volume); + if (!bounds.has_value()) { + continue; + } + const voxel::EditBounds moved {bounds->min + part.origin, bounds->max + part.origin}; + out = out.has_value() ? voxel::boundsUnion(*out, moved) : moved; + } + return out; +} + +auto VoxelNode::raycastVoxels(glm::vec3 origin, glm::vec3 direction, float max_t) -> std::optional { + std::optional best; + float limit = max_t; + const auto cast = [&](const voxel::Volume& volume, glm::ivec3 at) { + std::optional hit = voxel::raycast(volume, origin - glm::vec3(at), direction, limit); + if (hit.has_value() && hit->t < limit) { + limit = hit->t; + hit->voxel += at; + best = hit; + } + }; + if (usesParts()) { + for (const VoxelPart& part : m_parts) { + if (part.volume != nullptr) { + cast(*part.volume, part.origin); + } + } + } else if (const voxel::Volume* current = volume()) { + cast(*current, m_voxel_origin); + } + return best; +} + auto VoxelNode::getVoxel(glm::vec3 pos) -> int { + if (usesParts()) { + const glm::ivec3 cell = toVoxel(pos); + for (const VoxelPart& part : m_parts) { + if (part.volume != nullptr && part.volume->isSolidAt(cell - part.origin)) { + return part.volume->materialAt(cell - part.origin); + } + } + return 0; + } const voxel::Volume* current = volume(); return current != nullptr ? current->materialAt(toIndex(toVoxel(pos))) : 0; } @@ -438,7 +555,9 @@ void VoxelNode::carveBox(glm::vec3 min, glm::vec3 max) { void VoxelNode::setLayers(int included_layers, int excluded_layers) { included = static_cast(included_layers); excluded = static_cast(excluded_layers); - physics::Simulator::setBodyLayers(m_body, {.included = included, .excluded = excluded}); + forEachBody([&](physics::BodyID body) { + physics::Simulator::setBodyLayers(body, {.included = included, .excluded = excluded}); + }); } void VoxelNode::includeLayer(int layer, bool value) { @@ -596,23 +715,36 @@ void VoxelNode::clearRegions() { } auto VoxelNode::getSize() -> glm::vec3 { + if (usesParts()) { + std::optional all; + for (const VoxelPart& part : m_parts) { + if (part.volume != nullptr) { + const voxel::EditBounds box {part.origin, part.origin + glm::ivec3(part.volume->voxelDims()) - 1}; + all = all.has_value() ? voxel::boundsUnion(*all, box) : box; + } + } + return all.has_value() ? glm::vec3(all->max - all->min + 1) : glm::vec3(0.0f); + } const voxel::Volume* current = volume(); return current != nullptr ? glm::vec3(current->voxelDims()) : glm::vec3(0.0f); } auto VoxelNode::getBounds() -> std::vector { - const voxel::Volume* current = volume(); - if (current == nullptr) { - return {}; - } - const std::optional bounds = voxel::occupiedBounds(*current); + const std::optional bounds = solidBounds(); if (!bounds.has_value()) { return {}; } - return {glm::vec3(bounds->min + m_voxel_origin), glm::vec3(bounds->max + m_voxel_origin)}; + return {glm::vec3(bounds->min), glm::vec3(bounds->max)}; } auto VoxelNode::getVoxelCount() -> int { + if (usesParts()) { + uint64_t total = 0; + for (const VoxelPart& part : m_parts) { + total += part.volume != nullptr ? part.volume->solidVoxelCount() : 0; + } + return static_cast(total); + } const voxel::Volume* current = volume(); return current != nullptr ? static_cast(current->solidVoxelCount()) : 0; } @@ -637,7 +769,7 @@ void VoxelNode::sleep() { return; } physics::Simulator::runPendingRequests(); - physics::Simulator::requestSleep(bodyID()); + forEachBody([](physics::BodyID body) { physics::Simulator::requestSleep(body); }); } void VoxelNode::wake() { @@ -650,7 +782,7 @@ void VoxelNode::wake() { return; } physics::Simulator::runPendingRequests(); - physics::Simulator::requestWake(bodyID()); + forEachBody([](physics::BodyID body) { physics::Simulator::requestWake(body); }); } void VoxelNode::publishPhysicsState( @@ -689,18 +821,21 @@ void VoxelNode::onEditorTransformChanged() { return; } physics::Simulator::runPendingRequests(); - physics::Simulator::setBodyTransform(bodyID(), world_position, world_rotation); + forEachBody([&](physics::BodyID body) { physics::Simulator::setBodyTransform(body, world_position, world_rotation); }); } void VoxelNode::handleContactBegin(const physics::BroadPhasePair& pair) { physics::BodyID other_body; - if (pair.a.body == m_body) { + if (ownsBody(pair.a.body)) { other_body = pair.b.body; - } else if (pair.b.body == m_body) { + } else if (ownsBody(pair.b.body)) { other_body = pair.a.body; } else { return; } + if (ownsBody(other_body)) { + return; + } const auto active = std::ranges::find(m_active_contacts, other_body, &ActiveContact::other_body); if (active != m_active_contacts.end()) { @@ -715,13 +850,16 @@ void VoxelNode::handleContactBegin(const physics::BroadPhasePair& pair) { void VoxelNode::handleContactEnd(const physics::BroadPhasePair& pair) { physics::BodyID other_body; - if (pair.a.body == m_body) { + if (ownsBody(pair.a.body)) { other_body = pair.b.body; - } else if (pair.b.body == m_body) { + } else if (ownsBody(pair.b.body)) { other_body = pair.a.body; } else { return; } + if (ownsBody(other_body)) { + return; + } const auto active = std::ranges::find(m_active_contacts, other_body, &ActiveContact::other_body); if (active == m_active_contacts.end()) { @@ -856,14 +994,14 @@ void VoxelNode::destroy() { void VoxelNode::onEnable() { m_debug_visible = true; - physics::Simulator::setBodyEnabled(m_body, true); - physics::Simulator::setShapeEnabled(m_shape, true); + forEachBody([](physics::BodyID body) { physics::Simulator::setBodyEnabled(body, true); }); + forEachShape([](physics::ShapeID shape) { physics::Simulator::setShapeEnabled(shape, true); }); } void VoxelNode::onDisable() { m_debug_visible = false; - physics::Simulator::setBodyEnabled(m_body, false); - physics::Simulator::setShapeEnabled(m_shape, false); + forEachBody([](physics::BodyID body) { physics::Simulator::setBodyEnabled(body, false); }); + forEachShape([](physics::ShapeID shape) { physics::Simulator::setShapeEnabled(shape, false); }); } void VoxelNode::drawDebug() { @@ -879,13 +1017,15 @@ void VoxelNode::drawDebug() { debug::drawShapeBox(transform, draw_color, aabb_fill); }; - if (const auto bounds = physics::Simulator::shapeWorldBounds(m_shape)) { - draw_bounds(*bounds, awake, aabb_color); - } + forEachShape([&](physics::ShapeID shape) { + if (const auto bounds = physics::Simulator::shapeWorldBounds(shape)) { + draw_bounds(*bounds, awake, aabb_color); + } + }); static const glm::vec4 fragment_aabb_color {0.2f, 0.9f, 1.0f, 0.6f}; for (const physics::VoxelRenderRecord& record : physics::Simulator::voxelFragmentRecords()) { - if (record.fragment_origin == m_body) { + if (ownsBody(record.fragment_origin)) { draw_bounds(record.world_bounds, record.awake, fragment_aabb_color); } } diff --git a/engine/src/toast/voxel/nodes/voxel_node.hpp b/engine/src/toast/voxel/nodes/voxel_node.hpp index 905102e84..bc675397d 100644 --- a/engine/src/toast/voxel/nodes/voxel_node.hpp +++ b/engine/src/toast/voxel/nodes/voxel_node.hpp @@ -10,12 +10,15 @@ #include #include +#include +#include #include #include #include #include #include #include +#include #include #include @@ -57,6 +60,28 @@ enum class SimulationType : uint8_t { dynamic = 2, ///< Fully simulated }; +struct VoxelPart { + uint64_t key = 0; + std::unique_ptr volume; + std::vector> retired; + glm::ivec3 origin {0}; + uint32_t revision = 0; + bool split_pending = false; + physics::BodyID body; + physics::ShapeID shape; + std::unique_ptr highlight; + + bool has_pose = false; + glm::vec3 pose_position {0.0f}; + glm::quat pose_rotation {1.0f, 0.0f, 0.0f, 0.0f}; + float mass = 0.0f; + + [[nodiscard]] + auto localTransform() const -> glm::mat4 { + return glm::translate(glm::mat4(1.0f), glm::vec3(origin) * voxel::k_voxel_size); + } +}; + /** * A shape made of voxels that renders and collides * @@ -350,6 +375,33 @@ class [[ToastNode, Icon("BoxMesh")]] TOAST_API VoxelNode : public Voxel { return render_only; } + [[nodiscard]] + virtual auto usesParts() const noexcept -> bool { + return false; + } + + [[nodiscard]] + auto parts() const noexcept -> const std::vector& { + return m_parts; + } + + [[nodiscard]] + auto partsRevision() const noexcept -> uint32_t { + return m_parts_revision; + } + + [[nodiscard]] + auto ownsBody(physics::BodyID body) const noexcept -> bool; + + [[nodiscard]] + auto isSolidAtVoxel(glm::ivec3 voxel) -> bool; + + [[nodiscard]] + auto solidBounds() -> std::optional; + + [[nodiscard]] + auto raycastVoxels(glm::vec3 origin, glm::vec3 direction, float max_t) -> std::optional; + protected: /** * Runs right before editShape on every rebuild, subclasses build their shape here @@ -397,8 +449,10 @@ class [[ToastNode, Icon("BoxMesh")]] TOAST_API VoxelNode : public Voxel { void refreshVolume(); void releaseVolume(); + void reregisterPhysics(); void retireVolume(); void recordDamage(uint32_t count, glm::vec3 index_sum); + void recordDamage(uint32_t count, glm::vec3 index_sum, glm::ivec3 origin); /** * Grows the volume until bounds fit @@ -445,6 +499,30 @@ class [[ToastNode, Icon("BoxMesh")]] TOAST_API VoxelNode : public Voxel { return m_body != physics::BodyID {}; } + template + void forEachBody(Visit&& visit) { + if (m_body != physics::BodyID {}) { + visit(m_body); + } + for (const VoxelPart& part : m_parts) { + if (part.body != physics::BodyID {}) { + visit(part.body); + } + } + } + + template + void forEachShape(Visit&& visit) { + if (m_shape != physics::ShapeID {}) { + visit(m_shape); + } + for (const VoxelPart& part : m_parts) { + if (part.shape != physics::ShapeID {}) { + visit(part.shape); + } + } + } + void assignBody(physics::BodyID body) noexcept { m_body = body; } void assignShape(physics::ShapeID shape) noexcept { m_shape = shape; } @@ -554,6 +632,9 @@ class [[ToastNode, Icon("BoxMesh")]] TOAST_API VoxelNode : public Voxel { std::unique_ptr m_volume; std::vector> m_retired_volumes; + std::vector m_parts; + uint32_t m_parts_revision = 0; + bool m_warned_part_edit = false; glm::ivec3 m_voxel_origin {0}; uint32_t m_revision = 0; const assets::VoxelModel* m_instanced_from = nullptr; diff --git a/engine/src/toast/world/workspace.cpp b/engine/src/toast/world/workspace.cpp index 19e42ed72..937e94355 100644 --- a/engine/src/toast/world/workspace.cpp +++ b/engine/src/toast/world/workspace.cpp @@ -845,7 +845,7 @@ auto Workspace::retypeNode(Box& target, std::string_view type, bool keep_f return fresh; } -void Workspace::promoteNode(Box& target, std::string_view path) { +auto Workspace::promoteNode(Box& target, std::string_view path) -> Box { ZoneScoped; auto parent = target->parentInternal(); @@ -874,10 +874,11 @@ void Workspace::promoteNode(Box& target, std::string_view path) { if (auto* spawned_3d = spawned.exists() ? reflect_cast(&*spawned) : nullptr; spawned_3d && placement) { std::tie(spawned_3d->position, spawned_3d->rotation, spawned_3d->scale) = *placement; } - } else { - TOAST_WARN("World", "Promote: couldn't resolve UID for {}", path); - event::send(); + return spawned; } + TOAST_WARN("World", "Promote: couldn't resolve UID for {}", path); + event::send(); + return {}; } auto Workspace::restoreHistorySnapshot(const assets::Prefab& snapshot) -> bool { diff --git a/engine/src/toast/world/workspace.hpp b/engine/src/toast/world/workspace.hpp index 703507f87..b6c268cfa 100644 --- a/engine/src/toast/world/workspace.hpp +++ b/engine/src/toast/world/workspace.hpp @@ -254,7 +254,7 @@ class TOAST_API Workspace : public INodeOwner { std::optional m_pick_press; auto retypeNode(Box& target, std::string_view type, bool keep_fields) -> Box; - void promoteNode(Box& target, std::string_view path); + auto promoteNode(Box& target, std::string_view path) -> Box; template void recordHistory(WorkspaceHistory::Context context, F&& mutation) { diff --git a/engine/src/toast/world/workspace_events.cpp b/engine/src/toast/world/workspace_events.cpp index 254fc1809..e207e8f47 100644 --- a/engine/src/toast/world/workspace_events.cpp +++ b/engine/src/toast/world/workspace_events.cpp @@ -1369,6 +1369,12 @@ struct ProtoTraits { p.set_target(e.target); p.set_path(e.path); p.set_replace(e.replace); + p.set_base_path(e.base_path); + for (const VoxelBakePiece& piece : e.pieces) { + proto::events::VoxelBakePiece* out = p.add_pieces(); + out->set_uid(piece.uid); + out->set_path(piece.path); + } return p; } @@ -1377,6 +1383,10 @@ struct ProtoTraits { e.target = toast::UID::fromString(p.target()); e.path = p.path(); e.replace = p.replace(); + e.base_path = p.base_path(); + for (const proto::events::VoxelBakePiece& piece : p.pieces()) { + e.pieces.push_back({.uid = toast::UID::fromString(piece.uid()), .path = piece.path()}); + } return e; } }; @@ -1391,12 +1401,18 @@ struct ProtoTraits { static auto toProto(const Event& e) -> Proto { Proto p; p.set_path(e.path); + for (const std::string& unused : e.unused) { + p.add_unused(unused); + } return p; } static auto fromProto(const Proto& p) -> Event { Event e; e.path = p.path(); + for (const std::string& unused : p.unused()) { + e.unused.push_back(unused); + } return e; } }; diff --git a/engine/src/toast/world/workspace_events.hpp b/engine/src/toast/world/workspace_events.hpp index 80f178975..a1f782136 100644 --- a/engine/src/toast/world/workspace_events.hpp +++ b/engine/src/toast/world/workspace_events.hpp @@ -263,14 +263,23 @@ struct VoxelCollapsePieces : Event { std::string path; }; +/** Every separate mesh the baker bakes when baking the unbaked */ +struct VoxelBakePiece { + toast::UID uid; + std::string path; +}; + struct VoxelBake : Event { toast::UID target; std::string path; bool replace = false; + std::string base_path; + std::vector pieces; }; struct VoxelBakeCompleted : Event { std::string path; + std::vector unused; }; struct SetVoxelEditorOverlays : Event { diff --git a/engine/src/toast/world/workspace_voxel.cpp b/engine/src/toast/world/workspace_voxel.cpp index 29614f4f4..0e1726399 100644 --- a/engine/src/toast/world/workspace_voxel.cpp +++ b/engine/src/toast/world/workspace_voxel.cpp @@ -4,6 +4,9 @@ #include "workspace_events.hpp" #include +#include +#include +#include #include #include #include @@ -19,6 +22,7 @@ #include #include #include +#include namespace toast { @@ -188,13 +192,8 @@ auto voxelCellOnPlane(const ShapeRay& ray, int axis, float coordinate) -> std::o [[nodiscard]] auto voxelShapeHit(ProceduralVoxel& shape, const ShapeRay& ray) -> std::optional { - const voxel::Volume* volume = shape.volume(); - if (volume == nullptr) { - return std::nullopt; - } - std::optional hit = voxel::raycast(*volume, ray.origin - glm::vec3(shape.voxelOrigin()), ray.direction, 1e6f); + std::optional hit = shape.raycastVoxels(ray.origin, ray.direction, 1e6f); if (hit.has_value()) { - hit->voxel += shape.voxelOrigin(); if (hit->normal == glm::ivec3(0)) { hit->normal = -voxel::dominantAxis(ray.direction); } @@ -977,8 +976,7 @@ void Workspace::subscribeVoxelEditing() { if (shape == nullptr && target.exists()) { shape = owningProceduralVoxel(*target); } - const voxel::Volume* volume = shape != nullptr ? shape->volume() : nullptr; - if (volume == nullptr) { + if (shape == nullptr || !shape->solidBounds().has_value()) { TOAST_WARN("Voxel", "Bucket needs a ProceduralVoxel with voxels"); return true; } @@ -988,12 +986,12 @@ void Workspace::subscribeVoxelEditing() { if (e.search && e.search_axis < 3) { glm::ivec3 step(0); step[e.search_axis] = e.search_step; - const int32_t limit = static_cast(volume->voxelDims()[e.search_axis]) + 1; - for (int32_t i = 0; i < limit && !volume->isSolidAt(seed - shape->voxelOrigin()); ++i) { + const int32_t limit = static_cast(shape->getSize()[e.search_axis]) + 1; + for (int32_t i = 0; i < limit && !shape->isSolidAtVoxel(seed); ++i) { seed += step; } } - if (!volume->isSolidAt(seed - shape->voxelOrigin())) { + if (!shape->isSolidAtVoxel(seed)) { return true; } @@ -1100,58 +1098,209 @@ void Workspace::subscribeVoxelEditing() { return true; } - shape->rebuild(); - std::vector pieces; - pieces.reserve(shape->layout().size()); - for (const ProceduralVoxel::PieceLayout& entry : shape->layout()) { - if (auto* piece = voxelPieceOf(entry.node)) { - pieces.push_back(piece); + // c# makes all of the files and c++ writes the contents + // dw bro give up if u can't make it + // alexey ahh algorithm + const auto give_up = [&](std::string_view reason) { + TOAST_WARN("Voxel", "Bake: {}", reason); + event::VoxelBakeCompleted done; + done.unused.push_back(e.path); + if (!e.base_path.empty()) { + done.unused.push_back(e.base_path); + } + for (const event::VoxelBakePiece& piece : e.pieces) { + done.unused.push_back(piece.path); } + event::send(done); + }; + + Box parent = target->parentInternal(); + if (!parent.exists()) { + give_up("the root of a workspace cannot become a prefab"); + return true; } try { - std::optional composed = shape->composePieces(pieces); - if (!composed.has_value()) { - TOAST_WARN("Voxel", "Bake: the ProceduralVoxel draws nothing"); - return true; + struct Written { + glm::ivec3 origin {0}; + UID model; + }; + // by piece + std::unordered_map written; + + { + std::unordered_map paths; + for (const event::VoxelBakePiece& piece : e.pieces) { + paths[piece.uid.data()] = piece.path; + } + + std::vector parts = shape->bakeParts(); + for (const ProceduralVoxel::BakedPart& part : parts) { + std::string_view path; + if (part.key == 0) { + path = e.base_path; + } else if (const auto found = paths.find(part.key); found != paths.end()) { + path = found->second; + } + if (path.empty()) { + continue; + } + + const std::vector bytes = + assets::VoxelModel::capture(*part.volume, shape->paletteUid())->serialize(assets::SaveMode::editor); + if (!assets::AssetManager::get().saveBytes(path, bytes)) { + TOAST_WARN("Voxel", "Bake could not write {}", path); + continue; + } + const std::optional model_uid = assets::resolveURI(path); + if (!model_uid.has_value()) { + TOAST_WARN("Voxel", "Bake: {} is not in the asset manifest", path); + continue; + } + written.emplace(part.key, Written {.origin = part.origin, .model = *model_uid}); + + event::VoxelBakeCompleted completed; + completed.path = std::string(path); + event::send(completed); + } } - const std::vector bytes = - assets::VoxelModel::capture(composed->volume, shape->paletteUid())->serialize(assets::SaveMode::editor); - if (!assets::AssetManager::get().saveBytes(e.path, bytes)) { - TOAST_WARN("Voxel", "Bake could not write {}", e.path); + + if (written.empty()) { + give_up("the ProceduralVoxel draws nothing"); return true; } - event::VoxelBakeCompleted completed; - completed.path = e.path; - event::send(completed); + + event::VoxelBakeCompleted leftovers; if (!e.replace) { - return true; + leftovers.unused.push_back(e.path); } - - const std::optional model_uid = assets::resolveURI(e.path); - if (!model_uid.has_value()) { - TOAST_WARN("Voxel", "Bake: {} is not in the asset manifest", e.path); + if (!e.base_path.empty() && !written.contains(0)) { + leftovers.unused.push_back(e.base_path); + } + for (const event::VoxelBakePiece& piece : e.pieces) { + if (!written.contains(piece.uid.data())) { + leftovers.unused.push_back(piece.path); + } + } + if (!leftovers.unused.empty()) { + event::send(leftovers); + } + if (!e.replace) { return true; } - recordHistory(voxelHistory(event::HistoryOperation::retype, target, "Baked"), [&] { - Box replacement = retypeNode(target, "toast::VoxelNode", true); - auto* voxel_node = replacement.exists() ? reflect_cast(&*replacement) : nullptr; - if (voxel_node == nullptr) { + const auto copy_fields = [](Node& from, Node& to) { + constexpr std::array k_skipped { + "m_uid", "m_parent", "m_source_prefab", "m_name", "m_model", "position", "rotation", "scale", "m_script_group" + }; + from.info()->forEachBaseType([&](const NodeInfo& level) { + for (const auto& f : level.all_fields) { + if (!f.get || f.hasAttribute("ReadOnly") || f.hasAttribute("NoSerialize") || + std::ranges::find(k_skipped, std::string_view(f.name)) != k_skipped.end()) { + continue; + } + const FieldInfo* destination = to.info()->getField(f.name); + if (destination != nullptr && destination->set && destination->value_type == f.value_type && + destination->is_array == f.is_array) { + destination->set(&to, f.get(&from)); + } + } + }); + }; + + recordHistory(voxelHistory(event::HistoryOperation::promote, target, "Baked"), [&] { + const auto here = std::ranges::find(parent->m_children, target); + const auto index = static_cast(here - parent->m_children.begin()); + + shape->syncTransform(); + Box root = requestRuntimeCreate(parent, "toast::Node3D"); + auto* root_3d = root.exists() ? reflect_cast(&*root) : nullptr; + if (root_3d == nullptr) { return; } + root->name(shape->name()); + root_3d->position = shape->position; + root_3d->rotation = shape->rotation; + root_3d->scale = shape->scale; + root_3d->syncTransform(); + + const auto make_model = [&](Box into, const glm::mat4& into_to_shape, std::string_view name, const Written& model) { + Box made = requestRuntimeCreate(into, "toast::VoxelNode"); + auto* voxels = made.exists() ? reflect_cast(&*made) : nullptr; + if (voxels == nullptr) { + return; + } + made->name(name); + copy_fields(*shape, *voxels); + voxels->setModel(assets::load(model.model)); + + const glm::mat4 local = + glm::inverse(into_to_shape) * glm::translate(glm::mat4(1.0f), glm::vec3(model.origin) * voxel::k_voxel_size); + voxels->position = glm::vec3(local[3]); + voxels->rotation = glm::normalize(glm::quat_cast(glm::mat3(local))); + voxels->scale = glm::vec3(1.0f); + voxels->syncTransform(); + }; + + if (const auto base = written.find(0); base != written.end()) { + make_model(root, glm::mat4(1.0f), "Model", base->second); + } - // The baked asset begins at zero - const glm::vec3 offset = glm::vec3(composed->origin) * voxel::k_voxel_size; - voxel_node->position += voxel_node->rotation * (voxel_node->scale * offset); - voxel_node->syncTransform(); - voxel_node->setModel(assets::load(*model_uid)); - - // Retyping preserves compatible node fields - for (Box& child : replacement->m_children) { - destroyOwnedTree(child); + // groups stay groups + // every other piece becomes a VoxelNode + const auto mirror = [&](this auto&& self, Node& source, Box into, const glm::mat4& into_to_shape) -> void { + std::vector> others; + for (const Box& child_box : source.children()) { + if (!child_box.exists()) { + continue; + } + Node& child = const_cast(*child_box); + if (auto* group = reflect_cast(&child)) { + if (!child.enabled()) { + continue; + } + Box made = requestRuntimeCreate(into, "toast::Node3D"); + auto* made_3d = made.exists() ? reflect_cast(&*made) : nullptr; + if (made_3d == nullptr) { + continue; + } + group->syncTransform(); + made->name(group->name()); + made_3d->position = group->position; + made_3d->rotation = group->rotation; + made_3d->scale = group->scale; + made_3d->syncTransform(); + self(child, made, into_to_shape * group->getTransform()); + } else if (auto* piece = reflect_cast(&child)) { + if (!child.enabled()) { + continue; + } + if (piece->kind() == PieceKind::fill) { + if (const auto found = written.find(ProceduralVoxel::partKeyOf(*piece)); found != written.end()) { + make_model(into, into_to_shape, piece->name(), found->second); + } + } + self(child, into, into_to_shape); + } else if (reflect_cast(&child) == nullptr) { + others.push_back(child_box); + } + } + for (Box& other : others) { + voxelReparent(other, into, into->m_children.size()); + } + }; + mirror(*shape, root, glm::mat4(1.0f)); + + std::erase(parent->m_children, target); + destroyOwnedTree(target); + + // prefab replaces the shape + Box instance = promoteNode(root, e.path); + if (instance.exists()) { + auto& siblings = parent->m_children; + std::erase(siblings, instance); + siblings.insert(siblings.begin() + static_cast(std::min(index, siblings.size())), instance); } - replacement->m_children.clear(); }); event::send(); } catch (const std::exception& error) { TOAST_WARN("Voxel", "Bake failed: {}", error.what()); } @@ -1220,6 +1369,7 @@ auto Workspace::pickNodeUnderMouse() -> Box { struct Candidate { Box node; VoxelNode* voxels = nullptr; + const voxel::Volume* volume = nullptr; MeshNode* mesh = nullptr; glm::mat4 to_local {1.0f}; float entry = 0.0f; @@ -1232,12 +1382,22 @@ auto Workspace::pickNodeUnderMouse() -> Box { return; } if (auto* voxels = reflect_cast(&*node)) { - if (const voxel::Volume* volume = voxels->volume()) { - voxels->syncTransform(); - const glm::mat4 model = voxels->getWorldTransform() * voxels->volumeLocalTransform(); + voxels->syncTransform(); + const auto add = [&](const voxel::Volume* volume, const glm::mat4& model) { if (const auto entry = raySphereEntry(*ray, direction, voxel::worldBoundingSphere(model, volume->brickDims()))) { - candidates.push_back({.node = node, .voxels = voxels, .to_local = glm::inverse(model), .entry = *entry}); + candidates.push_back( + {.node = node, .voxels = voxels, .volume = volume, .to_local = glm::inverse(model), .entry = *entry} + ); } + }; + if (voxels->usesParts()) { + for (const VoxelPart& part : voxels->parts()) { + if (part.volume != nullptr) { + add(part.volume.get(), voxels->getWorldTransform() * part.localTransform()); + } + } + } else if (const voxel::Volume* volume = voxels->volume()) { + add(volume, voxels->getWorldTransform() * voxels->volumeLocalTransform()); } } else if (auto* mesh = reflect_cast(&*node); mesh != nullptr && mesh->getMesh().hasValue()) { mesh->syncTransform(); @@ -1276,10 +1436,9 @@ auto Workspace::pickNodeUnderMouse() -> Box { const glm::vec3 step = glm::vec3(candidate.to_local * glm::vec4(direction, 0.0f)); std::optional t; if (candidate.voxels != nullptr) { - if (const voxel::Volume* volume = candidate.voxels->volume()) { - if (const auto hit = voxel::raycast(*volume, origin / voxel::k_voxel_size, step / voxel::k_voxel_size, best_t)) { - t = hit->t; - } + if (const auto hit = + voxel::raycast(*candidate.volume, origin / voxel::k_voxel_size, step / voxel::k_voxel_size, best_t)) { + t = hit->t; } } else { t = meshRayHit(candidate.mesh->getMesh().get(), origin, step, best_t); diff --git a/protos/workspace_events.proto b/protos/workspace_events.proto index d9df00ded..c4c74ac80 100644 --- a/protos/workspace_events.proto +++ b/protos/workspace_events.proto @@ -240,14 +240,22 @@ message VoxelCollapsePieces { string path = 2; } +message VoxelBakePiece { + string uid = 1; + string path = 2; +} + message VoxelBake { string target = 1; string path = 2; bool replace = 3; + string base_path = 4; + repeated VoxelBakePiece pieces = 5; } message VoxelBakeCompleted { string path = 1; + repeated string unused = 2; } message SetVoxelEditorOverlays { From a4a9dd6174e9c565ccedb3fc9a9474ddb93cc62d Mon Sep 17 00:00:00 2001 From: Xein Date: Fri, 23 Oct 2026 12:19:04 +0200 Subject: [PATCH 3/5] Convert to debris --- engine/assets/shaders/debris.slang | 12 ++- engine/src/toast/physics/debris.hpp | 88 +++++++++++-------- engine/src/toast/physics/physics_settings.cpp | 4 - engine/src/toast/physics/physics_settings.hpp | 1 - engine/src/toast/physics/simulator.cpp | 59 +++++++++++-- engine/src/toast/physics/simulator.hpp | 4 +- 6 files changed, 114 insertions(+), 54 deletions(-) diff --git a/engine/assets/shaders/debris.slang b/engine/assets/shaders/debris.slang index 018059570..83fb41484 100644 --- a/engine/assets/shaders/debris.slang +++ b/engine/assets/shaders/debris.slang @@ -119,6 +119,7 @@ static const float kSleepSpeed = 0.2; // below this speed a piece may slee 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 +static const float kFloorZ = 0.0f; // lowest height a piece reaches // Graphics queue patches sections in place unordered with this queue so new records can still be uninitialized headroom // No robust buffer access so every index is checked against the buffer @@ -363,7 +364,7 @@ void integrateMain(uint3 id : SV_DispatchThreadID, uint3 groupThread : SV_GroupT if ((particle.packed & kFlagAsleep) != 0u) { // Wakes once nothing supports it - bool supported = false; + bool supported = particle.position.z <= kFloorZ + 2.0 * kContactSlop; for (uint bit = 0u; bit < 8u && !supported; ++bit) { if (((mask >> bit) & 1u) != 0u) { float3 below = particle.position + rotateByQuat(particle.rotation, bitOffset(bit, motion.baseScale)) - float3(0.0, 0.0, gParams.voxelSize); @@ -387,6 +388,15 @@ void integrateMain(uint3 id : SV_DispatchThreadID, uint3 groupThread : SV_GroupT particle.velocity += gParams.gravity * dt; particle.position += particle.velocity * dt; + // Nothing goes under the ground plane + if (particle.position.z < kFloorZ) { + particle.position.z = kFloorZ; + // It stops dead, otherwise it keeps spinning and sliding along the floor + particle.velocity = float3(0.0, 0.0, 0.0); + motion.angular = float3(0.0, 0.0, 0.0); + touching = true; + } + 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)); diff --git a/engine/src/toast/physics/debris.hpp b/engine/src/toast/physics/debris.hpp index cc4240e3a..87e0cdabf 100644 --- a/engine/src/toast/physics/debris.hpp +++ b/engine/src/toast/physics/debris.hpp @@ -33,6 +33,10 @@ struct DebrisCluster { glm::ivec3 origin {0}; uint8_t mask = 0; uint8_t material = 0; + bool whole = false; + glm::vec3 centre {0.0f}; + float scale = 1.0f; + uint32_t voxel_count = 0; }; struct DebrisSpawn { @@ -70,8 +74,35 @@ constexpr auto debrisBit(const glm::ivec3& local) -> uint32_t { } [[nodiscard]] -inline auto clusterDebris(std::span voxels, const glm::ivec3& jitter, Rng& rng, float split_chance) - -> std::vector { +inline auto wholeDebris(std::span voxels) -> DebrisCluster { + DebrisCluster out; + out.whole = true; + glm::ivec3 low = voxels.front().voxel; + glm::ivec3 high = low; + std::array counts {}; + for (const DebrisVoxel& v : voxels) { + low = glm::min(low, v.voxel); + high = glm::max(high, v.voxel); + ++counts[v.material]; + } + + const glm::vec3 extent = glm::vec3(high - low + 1); + out.centre = (glm::vec3(low) + glm::vec3(high + 1)) * 0.5f; + out.scale = std::max({extent.x, extent.y, extent.z}) * 0.5f; + out.voxel_count = static_cast(voxels.size()); + out.material = static_cast(std::ranges::max_element(counts) - counts.begin()); + + for (const DebrisVoxel& v : voxels) { + const glm::vec3 point = glm::vec3(v.voxel) + 0.5f; + const uint32_t bit = + (point.x >= out.centre.x ? 1u : 0u) | (point.y >= out.centre.y ? 2u : 0u) | (point.z >= out.centre.z ? 4u : 0u); + out.mask |= static_cast(1u << bit); + } + return out; +} + +[[nodiscard]] +inline auto clusterDebris(std::span voxels, const glm::ivec3& jitter) -> std::vector { struct Entry { glm::ivec3 cell; uint32_t bit; @@ -109,47 +140,28 @@ inline auto clusterDebris(std::span voxels, const glm::ivec3& ++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 {}; + uint8_t best = 0; + int best_count = 0; 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) { + if ((present & (1u << bit)) == 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]; - } + int same = 0; + for (uint32_t other = 0; other < 8; ++other) { + same += ((present & (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, - } - ); } + out.push_back( + DebrisCluster { + .origin = (entries[first].cell * 2) + jitter, + .mask = static_cast(present), + .material = best, + } + ); first = last; } return out; diff --git a/engine/src/toast/physics/physics_settings.cpp b/engine/src/toast/physics/physics_settings.cpp index 87c57052a..a9936d723 100644 --- a/engine/src/toast/physics/physics_settings.cpp +++ b/engine/src/toast/physics/physics_settings.cpp @@ -446,10 +446,6 @@ 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, diff --git a/engine/src/toast/physics/physics_settings.hpp b/engine/src/toast/physics/physics_settings.hpp index be1738851..bf280b206 100644 --- a/engine/src/toast/physics/physics_settings.hpp +++ b/engine/src/toast/physics/physics_settings.hpp @@ -43,7 +43,6 @@ 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; diff --git a/engine/src/toast/physics/simulator.cpp b/engine/src/toast/physics/simulator.cpp index 05bccea5b..ae012a5a8 100644 --- a/engine/src/toast/physics/simulator.cpp +++ b/engine/src/toast/physics/simulator.cpp @@ -827,11 +827,49 @@ void Simulator::clearFragmentFromSource( retireVoxelBody(shape->owner); return; } + if (debrisIfSmall(shape_id)) { + return; + } rebuildMassProperties(shape->owner); } +auto Simulator::debrisIfSmall(ShapeID shape_id) -> bool { + const Shape* shape = tryGetShape(shape_id); + VoxelShapeData* data = shape != nullptr && shape->type == ShapeType::voxel ? tryGetVoxelData(shape->voxel.data) : nullptr; + const Body* body = shape != nullptr ? tryGetBody(shape->owner) : nullptr; + if (data == nullptr || data->volume == nullptr || body == nullptr || body->type != BodyType::dynamic_body) { + return false; + } + + // Too few voxels to be worth a rigidbody + // convert them to debris + const uint32_t solid = data->volume->solidVoxelCount(); + if (solid == 0 || solid >= tunables().min_fragment_voxels) { + return false; + } + + DetachedComponent whole; + whole.voxel_count = solid; + for (uint32_t index = 0; index < data->volume->brickCount(); ++index) { + const glm::ivec3 brick = data->volume->brickAtIndex(index); + const voxel::BrickOccupancy* occupancy = data->volume->occupancyPointer(brick); + if (occupancy != nullptr && not voxel::isEmpty(*occupancy)) { + whole.pieces.push_back({.brick = brick, .voxels = *occupancy}); + } + } + + emitComponentDebris(shape_id, whole, nullptr); + shape = tryGetShape(shape_id); + data = shape != nullptr ? tryGetVoxelData(shape->voxel.data) : nullptr; + if (data != nullptr && data->volume != nullptr) { + clearFragmentFromSource(shape_id, *data, *data->volume, whole); + } + return true; +} + void Simulator::emitDebris( - ShapeID shape_id, std::span voxels, const DebrisBlast* blast, const FragmentPush* push, uint64_t seed + ShapeID shape_id, std::span voxels, const DebrisBlast* blast, const FragmentPush* push, uint64_t seed, + bool together ) { ZoneScopedN("physics::EmitDebris"); @@ -853,7 +891,8 @@ void Simulator::emitDebris( 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); + std::vector clusters = + together ? std::vector {wholeDebris(voxels)} : clusterDebris(voxels, jitter); // past the cap a random subset is kept and the rest is deleted const size_t room = cap - m_debris_spawns.size(); @@ -878,14 +917,16 @@ void Simulator::emitDebris( 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 local = + cluster.whole ? cluster.centre * voxel::k_voxel_size : (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; + const float mass = + static_cast(cluster.whole ? cluster.voxel_count : 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); @@ -914,7 +955,7 @@ void Simulator::emitDebris( .position = position, .rotation = lattice_rotation, .velocity = velocity, - .angular_velocity = rng.direction() * rng.range(0.0f, k_debris_max_spin), + .angular_velocity = together ? body->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) | @@ -924,7 +965,7 @@ void Simulator::emitDebris( .glass = voxel::isGlass(look), .alpha = look.alpha, .ior = look.ior, - .scale = rng.range(k_debris_min_scale, 1.0f), + .scale = cluster.whole ? cluster.scale : rng.range(k_debris_min_scale, 1.0f), } ); } @@ -962,7 +1003,7 @@ void Simulator::emitComponentDebris(ShapeID shape_id, const DetachedComponent& c } 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); + emitDebris(shape_id, voxels, nullptr, push, seed, true); } auto Simulator::takeDebrisSpawns() -> std::vector { @@ -4545,7 +4586,7 @@ void Simulator::applyDamageCommand(const DamageCommand& c) { if (body->type == BodyType::dynamic_body) { if (volume.solidVoxelCount() == 0) { retireVoxelBody(shape->owner); - } else { + } else if (not debrisIfSmall(c.shape)) { rebuildMassProperties(shape->owner); } } diff --git a/engine/src/toast/physics/simulator.hpp b/engine/src/toast/physics/simulator.hpp index 53792628b..f2e65963e 100644 --- a/engine/src/toast/physics/simulator.hpp +++ b/engine/src/toast/physics/simulator.hpp @@ -596,7 +596,8 @@ class TOAST_API Simulator { ); void emitDebris( - ShapeID shape_id, std::span voxels, const DebrisBlast* blast, const FragmentPush* push, uint64_t seed + ShapeID shape_id, std::span voxels, const DebrisBlast* blast, const FragmentPush* push, uint64_t seed, + bool together = false ); void emitComponentDebris(ShapeID shape_id, const DetachedComponent& component, const FragmentPush* push); @@ -619,6 +620,7 @@ class TOAST_API Simulator { auto spawnStaticSplitBody(ShapeID source_shape_id, const DetachedComponent& component) -> bool; void despawnSettledFragments(float dt); void retireVoxelBody(BodyID id); + auto debrisIfSmall(ShapeID shape) -> bool; void destroyFragmentsOf(BodyID origin); [[nodiscard]] From 683484f3100e6c2df411fb6504ea0dfce6e95bcf Mon Sep 17 00:00:00 2001 From: Xein Date: Fri, 23 Oct 2026 17:36:55 +0200 Subject: [PATCH 4/5] optimized PlanShatter a shit ton --- engine/src/toast/physics/fracture.hpp | 3 + engine/src/toast/physics/fracture_random.hpp | 2 +- engine/src/toast/physics/shatter.hpp | 149 ++++++--- engine/src/toast/physics/simulator.cpp | 310 +++++++++++-------- engine/src/toast/physics/voxel_smash.hpp | 2 + 5 files changed, 294 insertions(+), 172 deletions(-) diff --git a/engine/src/toast/physics/fracture.hpp b/engine/src/toast/physics/fracture.hpp index ef3f92348..51ef03c2d 100644 --- a/engine/src/toast/physics/fracture.hpp +++ b/engine/src/toast/physics/fracture.hpp @@ -25,6 +25,7 @@ #include #include #include +#include #include #include @@ -223,6 +224,7 @@ inline auto reachesThroughMaterial(const voxel::Volume& volume, const glm::vec3& [[nodiscard]] inline auto reachableMaterial(const voxel::Volume& volume, const glm::vec3& start, const glm::vec3& end, float radius) -> std::unordered_set { + ZoneScopedN("physics::ReachableMaterial"); std::unordered_set out; // voxel closest to the hit @@ -461,6 +463,7 @@ inline auto planCrater( const voxel::Volume& volume, const voxel::Palette& palette, const voxel::MaterialLibrary& materials, const FractureRequest& request ) -> Crater { + ZoneScopedN("physics::PlanCrater"); Crater out { .start = request.point, .end = request.point, diff --git a/engine/src/toast/physics/fracture_random.hpp b/engine/src/toast/physics/fracture_random.hpp index 9181c0c62..b3dfe7823 100644 --- a/engine/src/toast/physics/fracture_random.hpp +++ b/engine/src/toast/physics/fracture_random.hpp @@ -91,7 +91,7 @@ inline auto valueNoise3(uint64_t seed, glm::vec3 p) noexcept -> float { 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); + return LARP(LARP(x00, x01, w.y), LARP(x10, x11, w.y), w.z); #undef LARP } diff --git a/engine/src/toast/physics/shatter.hpp b/engine/src/toast/physics/shatter.hpp index 451c83bb1..b7f692629 100644 --- a/engine/src/toast/physics/shatter.hpp +++ b/engine/src/toast/physics/shatter.hpp @@ -59,6 +59,7 @@ inline auto planShatter( const voxel::Volume& volume, const voxel::Palette& palette, const voxel::MaterialLibrary& materials, const Crater& crater, Hardness max_hardness ) -> ShatterPlan { + ZoneScopedN("physics::PlanShatter"); ShatterPlan out; out.band_outer = craterMaxRadius(crater); @@ -80,6 +81,29 @@ inline auto planShatter( const float min_width = 2.0f * threshold; const float max_width = std::max(tunables().fracture_max_shatter_distance, min_width); + const float cell_size = std::max(values.shatter_cell_size, threshold); + 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 width_for = [&](float mean_toughness) { + // widest band for the shatter the enerby pays for + const auto cost_of = [&](float w) { return mean_toughness * values.shatter_crack_cost * fill * band_volume_of(w); }; + if (cost_of(max_width) <= crater.shatter_energy) { + return max_width; + } + if (not(cost_of(min_width) < crater.shatter_energy)) { + return min_width; + } + 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; + } + return low; + }; + // everything that could crack struct BandVoxel { glm::ivec3 voxel; @@ -89,52 +113,53 @@ inline auto planShatter( 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 float edge_min = craterMinRadius(crater); + const float edge_max = craterMaxRadius(crater); + const auto collect = [&](float limit) { + ZoneScopedN("physics::PlanShatter::Band"); + band.clear(); + toughness_sum = 0.0f; + const float reach = edge_max + limit; + 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 axis_distance = glm::length(center - axis_point); + if (axis_distance <= edge_min || axis_distance - edge_max > limit) { + return; + } + const float outside = axis_distance - craterRadiusAt(crater, center); + if (outside <= 0.0f || outside > limit) { + 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; } - 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; - } - ); + ); + }; + + // guess the width from what was hit + float width = width_for(crater.toughness); + collect(width); 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; + const float corrected = width_for(toughness_sum / static_cast(band.size())); + if (corrected > width) { + collect(corrected); + } else { + std::erase_if(band, [corrected](const BandVoxel& b) { return b.outside > corrected; }); } + width = corrected; out.width = width; - out.band_outer = craterMaxRadius(crater) + width; - std::erase_if(band, [width](const BandVoxel& b) { return b.outside > width; }); + out.band_outer = edge_max + width; out.band_voxels = band.size(); if (band.size() < 2) { out.stop = "no breakable material around the crater"; @@ -151,10 +176,14 @@ inline auto planShatter( // seeds Rng rng {streamSeed(crater.seed, FractureStream::shatter_points)}; const float min_gap = 2.0f * threshold; + const float min_gap_squared = min_gap * min_gap; 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; }); + const bool crowded = std::ranges::any_of(seeds, [&](const glm::vec3& s) { + const glm::vec3 offset = s - point; + return glm::dot(offset, offset) < min_gap_squared; + }); if (not crowded) { seeds.push_back(point); } @@ -174,21 +203,40 @@ inline auto planShatter( } else { grain[(dims.y >= dims.z ? 1 : 2)] = 1.0f; } - const auto metric = [&](const glm::vec3& offset) { + const float stretch = std::max(values.shatter_stretch, 1.0f); + const auto metric_squared = [&](const glm::vec3& offset) { if (not fibrous) { - return glm::length(offset); + return glm::dot(offset, 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)); + const float stretched = along / stretch; + return glm::dot(lateral, lateral) + (stretched * stretched); }; - std::unordered_set in_band; - in_band.reserve(band.size()); + glm::ivec3 band_low {std::numeric_limits::max()}; + glm::ivec3 band_high {std::numeric_limits::min()}; for (const BandVoxel& b : band) { - in_band.insert(voxelKey(b.voxel)); + band_low = glm::min(band_low, b.voxel); + band_high = glm::max(band_high, b.voxel); } + const glm::ivec3 band_dims = band_high - band_low + 1; + const auto band_index = [&](const glm::ivec3& v) { + const glm::ivec3 i = v - band_low; + return (((static_cast(i.z) * static_cast(band_dims.y)) + static_cast(i.y)) * + static_cast(band_dims.x)) + + static_cast(i.x); + }; + std::vector band_mask( + static_cast(band_dims.x) * static_cast(band_dims.y) * static_cast(band_dims.z), 0 + ); + for (const BandVoxel& b : band) { + band_mask[band_index(b.voxel)] = 1; + } + const auto in_band = [&](const glm::ivec3& v) { + return glm::all(glm::greaterThanEqual(v, band_low)) && glm::all(glm::lessThanEqual(v, band_high)) && + band_mask[band_index(v)] != 0; + }; // solid material const glm::ivec3 dims_i = glm::ivec3(dims); @@ -204,7 +252,7 @@ inline auto planShatter( 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))) { + volume.materialAt(n) == voxel::k_empty_palette_index || in_band(n)) { continue; } const glm::vec3 center = (glm::vec3 {n} + 0.5f) * voxel::k_voxel_size; @@ -229,7 +277,7 @@ inline auto planShatter( 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]); + const float d = metric_squared(band[b].center - seeds[i]); if (d < d1) { d2 = d1; owner.second = owner.nearest; @@ -240,7 +288,7 @@ inline auto planShatter( owner.second = i; } } - owner.gap = d2 - d1; + owner.gap = std::sqrt(d2) - std::sqrt(d1); ++cell_voxels[owner.nearest]; } @@ -291,6 +339,7 @@ struct CoreSplit { [[nodiscard]] inline auto planCoreSplit(std::span core, const Crater& crater) -> CoreSplit { + ZoneScopedN("physics::PlanCoreSplit"); CoreSplit out; const FractureValues& values = crater.profile; if (not(crater.fragment_energy > 0.0f) || not(crater.radius > 0.0f)) { diff --git a/engine/src/toast/physics/simulator.cpp b/engine/src/toast/physics/simulator.cpp index ae012a5a8..b69c5bf8d 100644 --- a/engine/src/toast/physics/simulator.cpp +++ b/engine/src/toast/physics/simulator.cpp @@ -4412,57 +4412,60 @@ void Simulator::applyDamageCommand(const DamageCommand& c) { 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) { - const glm::ivec3 brick {bx, by, bz}; - if (volume.entryAt(brick).tag() == voxel::BrickTag::empty) { - continue; - } + { + ZoneNamedN(scan_zone, "physics::ApplyDamage::Scan", true); // NOLINT + 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) { + const glm::ivec3 brick {bx, by, bz}; + if (volume.entryAt(brick).tag() == voxel::BrickTag::empty) { + continue; + } - 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) { - for (int32_t y = brick_first.y; y <= brick_last.y; ++y) { - for (int32_t x = brick_first.x; x <= brick_last.x; ++x) { - const glm::ivec3 v {x, y, z}; + 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) { + for (int32_t y = brick_first.y; y <= brick_last.y; ++y) { + for (int32_t x = brick_first.x; x <= brick_last.x; ++x) { + const glm::ivec3 v {x, y, z}; - const uint8_t palette_index = volume.materialAt(v); - if (palette_index == voxel::k_empty_palette_index) { - continue; - } + const uint8_t palette_index = volume.materialAt(v); + if (palette_index == voxel::k_empty_palette_index) { + continue; + } - 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); - // cheap reject before paying for 8 noise taps - if (distance_squared > reach_squared) { - continue; - } - 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; - } + 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); + // cheap reject before paying for 8 noise taps + if (distance_squared > reach_squared) { + continue; + } + 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; + } - const uint32_t material_index = voxel::resolveMaterialIndex(data->palette, data->materials, palette_index); - const voxel::PhysicalMaterial& material = data->materials.materials[material_index]; - if (not material.breakableBy(c.max_hardness)) { - continue; - } + const uint32_t material_index = voxel::resolveMaterialIndex(data->palette, data->materials, palette_index); + const voxel::PhysicalMaterial& material = data->materials.materials[material_index]; + if (not material.breakableBy(c.max_hardness)) { + continue; + } - if (in_core) { - // stays in the wall for now - core.push_back({.voxel = v, .center = voxel_center}); - continue; + 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}); } - doomed.push_back({.voxel = v, .brick = brick, .density = material.density, .material = palette_index}); } } } @@ -4488,37 +4491,40 @@ void Simulator::applyDamageCommand(const DamageCommand& c) { } debris_voxels.push_back({.voxel = v, .material = palette_index}); }; - for (const Doomed& d : doomed) { - carve(d.voxel, d.material, d.density); - } + { + ZoneNamedN(carve_zone, "physics::ApplyDamage::Carve", true); // NOLINT + 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; + // 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); } - 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; + 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); } - 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()) { @@ -4945,6 +4951,7 @@ auto Simulator::explodeShape(ShapeID shape_id, const ExplosionDamage& explosion) { std::scoped_lock voxel_lock {voxelDataMutex()}; + ZoneNamedN(scan_zone, "physics::ExplodeShape::Scan", true); // NOLINT const voxel::Volume& volume = *data->volume; const voxel::MaterialLibrary& materials = data->materials; if (materials.materials.empty()) { @@ -4990,21 +4997,65 @@ auto Simulator::explodeShape(ShapeID shape_id, const ExplosionDamage& explosion) const auto cell_key = [](const glm::ivec3& cell, bool coarse) { return voxelKey(cell + glm::ivec3(1 << 19)) | (coarse ? k_coarse_bit : 0ull); }; - std::unordered_map seed_points; - const auto seed_point = [&](const glm::ivec3& cell, bool coarse) { - const auto [it, inserted] = seed_points.try_emplace(cell_key(cell, coarse)); - if (inserted) { - const uint64_t level_seed = jitter_seed + (coarse ? 8 : 0); + + // seed points in a flat grid over the scan box + struct SeedGrid { + float size = 0.0f; + uint64_t seed = 0; + glm::ivec3 low {0}; + glm::ivec3 dims {0}; + std::vector points; + std::vector ready; + }; + + const auto make_seed_grid = [&](float size, uint64_t level_seed) { + SeedGrid grid {.size = size, .seed = level_seed}; grid.low = glm::ivec3(glm::floor(((glm::vec3 {first} + 0.5f) * voxel::k_voxel_size) / size)) - 1; + const glm::ivec3 high = glm::ivec3(glm::floor(((glm::vec3 {last} + 0.5f) * voxel::k_voxel_size) / size)) + 1; + grid.dims = high - grid.low + 1; + const size_t count = static_cast(grid.dims.x) * static_cast(grid.dims.y) * static_cast(grid.dims.z); + grid.points.resize(count); + grid.ready.assign(count, 0); + return grid; + }; + SeedGrid fine_seeds = make_seed_grid(cell_size, jitter_seed); + SeedGrid coarse_seeds = make_seed_grid(2.0f * cell_size, jitter_seed + 8); + const auto seed_point = [](SeedGrid& grid, const glm::ivec3& cell) -> const glm::vec3& { + const glm::ivec3 i = cell - grid.low; + const size_t index = (((static_cast(i.z) * static_cast(grid.dims.y)) + static_cast(i.y)) * + static_cast(grid.dims.x)) + + static_cast(i.x); + if (grid.ready[index] == 0) { + grid.ready[index] = 1; const glm::vec3 jitter { - 0.2f + (0.6f * hash01(level_seed + 1, cell)), - 0.2f + (0.6f * hash01(level_seed + 2, cell)), - 0.2f + (0.6f * hash01(level_seed + 3, cell)), + 0.2f + (0.6f * hash01(grid.seed + 1, cell)), + 0.2f + (0.6f * hash01(grid.seed + 2, cell)), + 0.2f + (0.6f * hash01(grid.seed + 3, cell)), }; - it->second = (glm::vec3(cell) + jitter) * (coarse ? 2.0f * cell_size : cell_size); + grid.points[index] = (glm::vec3(cell) + jitter) * grid.size; } - return it->second; + return grid.points[index]; + }; + + // everything that depends on the material + struct ExplodedMaterial { + const voxel::PhysicalMaterial* material = nullptr; + float crack_reach = 0.0f; + bool breakable = false; }; + std::array exploded_materials {}; + for (uint32_t index = 0; index < voxel::k_palette_size; ++index) { + const voxel::PhysicalMaterial& material = + materials.materials[voxel::resolveMaterialIndex(data->palette, materials, static_cast(index))]; + const float hole = + material.toughness > 0.0f ? std::pow(explosion.force / material.toughness, 1.0f / explosion.falloff) : reach; + exploded_materials[index] = { + .material = &material, + .crack_reach = std::min(hole, reach) + band_width, + .breakable = material.breakableBy(explosion.max_hardness), + }; + } + const auto brick_dim = static_cast(voxel::k_brick_dim); const glm::ivec3 first_brick = first / brick_dim; const glm::ivec3 last_brick = last / brick_dim; @@ -5027,6 +5078,9 @@ auto Simulator::explodeShape(ShapeID shape_id, const ExplosionDamage& explosion) 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)); + BrickMask* brick_debris = nullptr; + BrickMask* chunk_mask = nullptr; + uint64_t chunk_mask_key = 0; for (int32_t z = brick_first.z; z <= brick_last.z; ++z) { for (int32_t y = brick_first.y; y <= brick_last.y; ++y) { for (int32_t x = brick_first.x; x <= brick_last.x; ++x) { @@ -5039,30 +5093,27 @@ auto Simulator::explodeShape(ShapeID shape_id, const ExplosionDamage& explosion) } const glm::vec3 center = (glm::vec3 {v} + 0.5f) * voxel::k_voxel_size; - const float distance = glm::length(center - local_point); - if (distance > scan) { + const glm::vec3 from_point = center - local_point; + const float distance_squared = glm::dot(from_point, from_point); + if (distance_squared > scan * scan) { continue; } const uint8_t palette_index = volume.materialAt(v); - const voxel::PhysicalMaterial& material = - materials.materials[voxel::resolveMaterialIndex(data->palette, materials, palette_index)]; - if (material.isIndestructible() || not material.breakableBy(explosion.max_hardness)) { + const ExplodedMaterial& exploded = exploded_materials[palette_index]; + if (not exploded.breakable) { continue; } + const voxel::PhysicalMaterial& material = *exploded.material; + const float distance = std::sqrt(distance_squared); const float edge_radius = craterRadiusAt(edge, center); const float reached = distance * (reach / std::max(edge_radius, 1.0e-4f)); const float density = explosionDensityAt(explosion.force, reached, explosion.falloff); const bool broken = density >= material.toughness; - if (not broken) { - // cracks just past the hole - const float hole = material.toughness > 0.0f - ? std::pow(explosion.force / material.toughness, 1.0f / explosion.falloff) - : reach; - if (not(reached <= std::min(hole, reach) + band_width)) { - continue; - } + // cracks just past the hole + if (not broken && not(reached <= exploded.crack_reach)) { + continue; } bool debris = broken && (material.toughness > 0.0f ? density >= debris_ratio * material.toughness : true); @@ -5070,7 +5121,9 @@ auto Simulator::explodeShape(ShapeID shape_id, const ExplosionDamage& explosion) bool coarse = false; if (not debris) { coarse = broken && reached > 0.5f * reach; - const glm::ivec3 home = glm::ivec3(glm::floor(center / (coarse ? 2.0f * cell_size : cell_size))); + SeedGrid& seeds = coarse ? coarse_seeds : fine_seeds; + const glm::ivec3 home = glm::ivec3(glm::floor(center / seeds.size)); + // squared distances until the two nearest are known float nearest = std::numeric_limits::max(); float second = nearest; glm::ivec3 next {0}; @@ -5078,7 +5131,8 @@ auto Simulator::explodeShape(ShapeID shape_id, const ExplosionDamage& explosion) for (int32_t dy = -1; dy <= 1; ++dy) { for (int32_t dx = -1; dx <= 1; ++dx) { const glm::ivec3 cell = home + glm::ivec3 {dx, dy, dz}; - const float d = glm::distance(center, seed_point(cell, coarse)); + const glm::vec3 offset = center - seed_point(seeds, cell); + const float d = glm::dot(offset, offset); if (d < nearest) { second = nearest; next = chunk; @@ -5095,7 +5149,7 @@ auto Simulator::explodeShape(ShapeID shape_id, const ExplosionDamage& explosion) // the hole needs no cracks if (not broken) { const bool loose = released(chunk); - if (second - nearest < crack_gap) { + if (std::sqrt(second) - std::sqrt(nearest) < crack_gap) { if (not(loose || released(next))) { continue; } @@ -5108,12 +5162,20 @@ auto Simulator::explodeShape(ShapeID shape_id, const ExplosionDamage& explosion) } if (debris) { - mark(debris_bricks[voxelKey(brick)], v, brick); + if (brick_debris == nullptr) { + brick_debris = &debris_bricks[voxelKey(brick)]; + } + mark(*brick_debris, v, brick); debris_voxels.push_back({.voxel = v, .material = palette_index}); debris_index_sum += glm::vec3(v); debris_energy += std::max(density - material.toughness, 0.0f) * k_voxel_volume; } else { - mark(chunk_cells[cell_key(chunk, coarse)][voxelKey(brick)], v, brick); + const uint64_t key = cell_key(chunk, coarse); + if (chunk_mask == nullptr || key != chunk_mask_key) { + chunk_mask = &chunk_cells[key][voxelKey(brick)]; + chunk_mask_key = key; + } + mark(*chunk_mask, v, brick); chunk_index_sum += glm::vec3(v); ++chunk_voxels; } @@ -5131,6 +5193,7 @@ auto Simulator::explodeShape(ShapeID shape_id, const ExplosionDamage& explosion) constexpr float k_explosion_debris_share = 0.2f; if (not debris_voxels.empty()) { + ZoneNamedN(debris_zone, "physics::ExplodeShape::Debris", true); // NOLINT const DebrisBlast blast { .start = local_point, .end = local_point, @@ -5151,27 +5214,31 @@ auto Simulator::explodeShape(ShapeID shape_id, const ExplosionDamage& explosion) } const AABB bounds {.min = explosion.point - glm::vec3(reach), .max = explosion.point + glm::vec3(reach)}; - SmashPieces chunks; - chunks.index_sum = chunk_index_sum; - chunks.voxels = chunk_voxels; - for (const auto& [cell, bricks] : chunk_cells) { - DetachedComponent piece; - piece.component = static_cast(chunks.pieces.size()); - for (const auto& [key, entry] : bricks) { - piece.voxel_count += voxel::popCount(entry.mask); - piece.pieces.push_back(voxel::BrickPiece {.brick = entry.brick, .voxels = entry.mask, .component = piece.component}); - } - chunks.pieces.push_back(std::move(piece)); - } - const size_t chunk_count = chunks.pieces.size(); - breakPieces( - shape_id, - chunks, - true, - [&](const DetachedComponent& piece) -> std::optional { return explosionPush(shape_id, piece, explosion); }, - bounds, - tunables().explosion_max_fragments - ); + size_t chunk_count = 0; + { + ZoneNamedN(chunks_zone, "physics::ExplodeShape::Chunks", true); // NOLINT + SmashPieces chunks; + chunks.index_sum = chunk_index_sum; + chunks.voxels = chunk_voxels; + for (const auto& [cell, bricks] : chunk_cells) { + DetachedComponent piece; + piece.component = static_cast(chunks.pieces.size()); + for (const auto& [key, entry] : bricks) { + piece.voxel_count += voxel::popCount(entry.mask); + piece.pieces.push_back(voxel::BrickPiece {.brick = entry.brick, .voxels = entry.mask, .component = piece.component}); + } + chunks.pieces.push_back(std::move(piece)); + } + chunk_count = chunks.pieces.size(); + breakPieces( + shape_id, + chunks, + true, + [&](const DetachedComponent& piece) -> std::optional { return explosionPush(shape_id, piece, explosion); }, + bounds, + tunables().explosion_max_fragments + ); + } shape = tryGetShape(shape_id); data = shape != nullptr ? tryGetVoxelData(shape->voxel.data) : nullptr; @@ -5248,6 +5315,7 @@ auto Simulator::breakPieces( const std::function(const DetachedComponent&)>& push_for, const AABB& wake_bounds, uint32_t max_spawns ) -> uint32_t { + ZoneScopedN("physics::BreakPieces"); const Shape* shape = tryGetShape(shape_id); VoxelShapeData* data = shape != nullptr ? tryGetVoxelData(shape->voxel.data) : nullptr; if (data == nullptr || picked.pieces.empty()) { diff --git a/engine/src/toast/physics/voxel_smash.hpp b/engine/src/toast/physics/voxel_smash.hpp index 96dc84fd0..a329e8cfe 100644 --- a/engine/src/toast/physics/voxel_smash.hpp +++ b/engine/src/toast/physics/voxel_smash.hpp @@ -17,6 +17,7 @@ #include #include #include +#include #include #include @@ -107,6 +108,7 @@ auto collectBrokenPieces( const voxel::Volume& volume, const voxel::Palette& palette, const voxel::MaterialLibrary& materials, const glm::vec3& low, const glm::vec3& high, Breaks&& breaks ) -> SmashPieces { + ZoneScopedN("physics::CollectBrokenPieces"); SmashPieces out; if (materials.materials.empty()) { return out; From 5cd33f5f324b75eccd9d5a88fd27e0c054b5ae91 Mon Sep 17 00:00:00 2001 From: Xein Date: Sat, 24 Oct 2026 11:43:12 +0200 Subject: [PATCH 5/5] fixed CI --- engine/src/toast/physics/simulator.cpp | 3 +- .../src/toast/renderer/passes/voxel_pass.cpp | 2 +- .../toast/voxel/nodes/procedural_voxel.cpp | 7 +- tests/node_file/sample.hpp | 190 +++++++++--------- .../01-format_and_handle.cpp | 20 +- 5 files changed, 113 insertions(+), 109 deletions(-) diff --git a/engine/src/toast/physics/simulator.cpp b/engine/src/toast/physics/simulator.cpp index b69c5bf8d..78edd58e7 100644 --- a/engine/src/toast/physics/simulator.cpp +++ b/engine/src/toast/physics/simulator.cpp @@ -5009,7 +5009,8 @@ auto Simulator::explodeShape(ShapeID shape_id, const ExplosionDamage& explosion) }; const auto make_seed_grid = [&](float size, uint64_t level_seed) { - SeedGrid grid {.size = size, .seed = level_seed}; grid.low = glm::ivec3(glm::floor(((glm::vec3 {first} + 0.5f) * voxel::k_voxel_size) / size)) - 1; + SeedGrid grid {.size = size, .seed = level_seed}; + grid.low = glm::ivec3(glm::floor(((glm::vec3 {first} + 0.5f) * voxel::k_voxel_size) / size)) - 1; const glm::ivec3 high = glm::ivec3(glm::floor(((glm::vec3 {last} + 0.5f) * voxel::k_voxel_size) / size)) + 1; grid.dims = high - grid.low + 1; const size_t count = static_cast(grid.dims.x) * static_cast(grid.dims.y) * static_cast(grid.dims.z); diff --git a/engine/src/toast/renderer/passes/voxel_pass.cpp b/engine/src/toast/renderer/passes/voxel_pass.cpp index 121906ea9..e52720d64 100644 --- a/engine/src/toast/renderer/passes/voxel_pass.cpp +++ b/engine/src/toast/renderer/passes/voxel_pass.cpp @@ -876,7 +876,7 @@ auto VoxelPass::prepare(uint32_t frame_index) -> bool { const bool capture = frame->probe_capture_index >= 0 || frame->irradiance_capture_index >= 0; m_denoise_active = (m_marched_lights || m_ambient_rays) && frame->render_mode == 0 && !capture && m_denoise_temporal_pipeline.isReady() && m_denoise_composite_pipeline.isReady() && - frame_index * 2 + 1 < m_denoise_sets.size() && m_denoise_history[1][1].isReady(); + (frame_index * 2) + 1 < m_denoise_sets.size() && m_denoise_history[1][1].isReady(); if (!m_denoise_active) { m_denoise_valid = false; } diff --git a/engine/src/toast/voxel/nodes/procedural_voxel.cpp b/engine/src/toast/voxel/nodes/procedural_voxel.cpp index 191677c8e..ae7099dba 100644 --- a/engine/src/toast/voxel/nodes/procedural_voxel.cpp +++ b/engine/src/toast/voxel/nodes/procedural_voxel.cpp @@ -24,6 +24,7 @@ #include #include #include +#include namespace toast { @@ -399,9 +400,9 @@ void visitSolid(const voxel::Volume& volume, glm::ivec3 brick, Visit&& visit) { const glm::ivec3 corner = brick * static_cast(voxel::k_brick_dim); if (entry.tag() == voxel::BrickTag::uniform) { const auto id = static_cast(entry.payload()); - for (int32_t z = 0; z < static_cast(voxel::k_brick_dim); ++z) { - for (int32_t y = 0; y < static_cast(voxel::k_brick_dim); ++y) { - for (int32_t x = 0; x < static_cast(voxel::k_brick_dim); ++x) { + for (int32_t z = 0; std::cmp_less(z, voxel::k_brick_dim); ++z) { + for (int32_t y = 0; std::cmp_less(y, voxel::k_brick_dim); ++y) { + for (int32_t x = 0; std::cmp_less(x, voxel::k_brick_dim); ++x) { visit(corner + glm::ivec3(x, y, z), id); } } diff --git a/tests/node_file/sample.hpp b/tests/node_file/sample.hpp index 405c60e4e..2340f0ee8 100644 --- a/tests/node_file/sample.hpp +++ b/tests/node_file/sample.hpp @@ -3,99 +3,99 @@ namespace { inline const std::string sample_text = - "~format @int = 4\n" - "\n" - "[station_master type=SpaceStationManager]\n" - "id @string = \"4e3d2c1b0a9\"\n" - "sector_name @string = \"Kepler Orbital Outpost Delta\"\n" - "orbit_radius_km @float = 42164\n" - "is_pressurized @bool = true\n" - "\n" - "[nebula_field type=CosmicParticleEmitter]\n" - "parent @string = \"station_master\"\n" - "id @string = \"8f7e6d5c4b3\"\n" - "enabled @bool = true\n" - ".emitter_settings\n" - " max_particles @int = 75000\n" - " texture_sheet_path @string = \"assets/textures/vfx/cosmic dust/nebula_cloud.png\"\n" - " spawn_rate @float = 250\n" - " ..kinematics\n" - " velocity_over_lifetime @vec3 = 0.02 -0.005 0.08\n" - " particle_lifetime_range @vec2 = 12 45\n" - " orbital_pull_centers @array_string = \"local_stellar_core\" \"collapsed_singularity\"\n" - "\n" - "[defense_node_alpha type=AutomatedTurret]\n" - "parent @string = \"4e3d2c1b0a9\"\n" - "id @string = \"a1b2c3d4e5f\"\n" - "enabled @bool = true\n" - ".transform\n" - " position @vec3 = 45.5 12 -118.4\n" - " rotation @vec4 = 0 0.382 0 0.924\n" - " scale @vec3 = 2 2 2\n" - ".weapon_system\n" - " fire_rate @float = 0.12\n" - " ammo_types_available @array_string = \"plasma_bolt\" \"emp_slug\" \"kinetic_disruptor\"\n" - " barrel_heat_dissipation @float = 15.4\n" - " ..targeting_matrix\n" - " scan_radius @float = 250\n" - " tracking_speed_deg @float = 90\n" - " priority_targets @array_string = \\\n" - " \"raider_interceptor\" \\\n" - " \"rogue_mining_drone\" \\\n" - " \"debris_hazard\"\n" - " blind_angle_bounds @array_float = 180 245\n" - "\n" - "[player_avatar type=PlayerEntity]\n" - "parent @string = \"a1b2c3d4e5f\"\n" - "id @string = \"7a8b9c0d1e2\"\n" - "enabled @bool = true\n" - ".transform\n" - " position @vec3 = 3.42 0 12.15\n" - " rotation @vec4 = 0 1 0 0\n" - " scale @vec3 = 1 1 1\n" - ".mesh_renderer\n" - " base_rig_path @string = \"assets/models/characters/avatar skeleton/human_base.fbx\"\n" - " lod_distances @array_float = 5 15 45\n" - ".wardrobe_manager\n" - " equipped_outfit_id @string = \"deep_space_vanguard_set\"\n" - " apparel_layers @array_string = \\\n" - " \"base_thermal_mesh\" \\\n" - " \"ballistic_breastplate\" \\\n" - " \"reflective_visor\" \\\n" - " \"heavy_trench_cloak\"\n" - " color_palette_hex @array_string = \"#1a1a1a\" \"#d4af37\" \"#e5e5e5\"\n" - ".custom_blendshapes\n" - " mesh_morph_weights @array_float = 0 1 0.25 0 0.85 0.4\n" - "\n" - "[interactive_synth_console type=MusicPuzzle]\n" - "parent @string = \"station_master\"\n" - "id @string = \"d4e5f6a7b8c\"\n" - "enabled @bool = true\n" - ".transform\n" - " position @vec3 = 0 1.1 14.5\n" - " rotation @vec4 = -0.258 0 0 0.966\n" - " scale @vec3 = 1.2 1 1\n" - ".audio_engine\n" - " base_frequency_hz @float = 110\n" - " chord_progression @array_string = \"Am7\" \"D9\" \"Gmaj7\" \"Cmaj7\"\n" - " step_sequencer_gate @array_int = 1 0 1 1 0 1 0 1 1 1 0 0 1 0 1 1\n" - " filter_cutoff_points @array_float = 220 440 880 1760 3520\n" - " ..synth_parameters\n" - " oscillator_type @string = \"polyphonic_sawtooth\"\n" - " detune_cents @float = 14.25\n" - " attack_decay_sustain_release @vec4 = 0.04 0.25 0.65 0.35\n" - " vibrato_lfo_speed @float = 6.2\n" - "\n" - "[deck_door_light type=PointLight]\n" - "parent @string = \"station_master\"\n" - "id @string = \"f9e8d7c6b5a\"\n" - "enabled @bool = false\n" - ".transform\n" - " position @vec3 = -12 4.2 8\n" - " rotation @vec4 = 0 0 0 1\n" - " scale @vec3 = 1 1 1\n" - ".light_properties\n" - " radius @float = 8.5\n" - " color @vec3 = 1 0.02 0.05\n" - " intensity @float = 12\n"; + "~format @int = 5\n" + "\n" + "[station_master type=SpaceStationManager]\n" + "id @string = \"4e3d2c1b0a9\"\n" + "sector_name @string = \"Kepler Orbital Outpost Delta\"\n" + "orbit_radius_km @float = 42164\n" + "is_pressurized @bool = true\n" + "\n" + "[nebula_field type=CosmicParticleEmitter]\n" + "parent @string = \"station_master\"\n" + "id @string = \"8f7e6d5c4b3\"\n" + "enabled @bool = true\n" + ".emitter_settings\n" + " max_particles @int = 75000\n" + " texture_sheet_path @string = \"assets/textures/vfx/cosmic dust/nebula_cloud.png\"\n" + " spawn_rate @float = 250\n" + " ..kinematics\n" + " velocity_over_lifetime @vec3 = 0.02 -0.005 0.08\n" + " particle_lifetime_range @vec2 = 12 45\n" + " orbital_pull_centers @array_string = \"local_stellar_core\" \"collapsed_singularity\"\n" + "\n" + "[defense_node_alpha type=AutomatedTurret]\n" + "parent @string = \"4e3d2c1b0a9\"\n" + "id @string = \"a1b2c3d4e5f\"\n" + "enabled @bool = true\n" + ".transform\n" + " position @vec3 = 45.5 12 -118.4\n" + " rotation @vec4 = 0 0.382 0 0.924\n" + " scale @vec3 = 2 2 2\n" + ".weapon_system\n" + " fire_rate @float = 0.12\n" + " ammo_types_available @array_string = \"plasma_bolt\" \"emp_slug\" \"kinetic_disruptor\"\n" + " barrel_heat_dissipation @float = 15.4\n" + " ..targeting_matrix\n" + " scan_radius @float = 250\n" + " tracking_speed_deg @float = 90\n" + " priority_targets @array_string = \\\n" + " \"raider_interceptor\" \\\n" + " \"rogue_mining_drone\" \\\n" + " \"debris_hazard\"\n" + " blind_angle_bounds @array_float = 180 245\n" + "\n" + "[player_avatar type=PlayerEntity]\n" + "parent @string = \"a1b2c3d4e5f\"\n" + "id @string = \"7a8b9c0d1e2\"\n" + "enabled @bool = true\n" + ".transform\n" + " position @vec3 = 3.42 0 12.15\n" + " rotation @vec4 = 0 1 0 0\n" + " scale @vec3 = 1 1 1\n" + ".mesh_renderer\n" + " base_rig_path @string = \"assets/models/characters/avatar skeleton/human_base.fbx\"\n" + " lod_distances @array_float = 5 15 45\n" + ".wardrobe_manager\n" + " equipped_outfit_id @string = \"deep_space_vanguard_set\"\n" + " apparel_layers @array_string = \\\n" + " \"base_thermal_mesh\" \\\n" + " \"ballistic_breastplate\" \\\n" + " \"reflective_visor\" \\\n" + " \"heavy_trench_cloak\"\n" + " color_palette_hex @array_string = \"#1a1a1a\" \"#d4af37\" \"#e5e5e5\"\n" + ".custom_blendshapes\n" + " mesh_morph_weights @array_float = 0 1 0.25 0 0.85 0.4\n" + "\n" + "[interactive_synth_console type=MusicPuzzle]\n" + "parent @string = \"station_master\"\n" + "id @string = \"d4e5f6a7b8c\"\n" + "enabled @bool = true\n" + ".transform\n" + " position @vec3 = 0 1.1 14.5\n" + " rotation @vec4 = -0.258 0 0 0.966\n" + " scale @vec3 = 1.2 1 1\n" + ".audio_engine\n" + " base_frequency_hz @float = 110\n" + " chord_progression @array_string = \"Am7\" \"D9\" \"Gmaj7\" \"Cmaj7\"\n" + " step_sequencer_gate @array_int = 1 0 1 1 0 1 0 1 1 1 0 0 1 0 1 1\n" + " filter_cutoff_points @array_float = 220 440 880 1760 3520\n" + " ..synth_parameters\n" + " oscillator_type @string = \"polyphonic_sawtooth\"\n" + " detune_cents @float = 14.25\n" + " attack_decay_sustain_release @vec4 = 0.04 0.25 0.65 0.35\n" + " vibrato_lfo_speed @float = 6.2\n" + "\n" + "[deck_door_light type=PointLight]\n" + "parent @string = \"station_master\"\n" + "id @string = \"f9e8d7c6b5a\"\n" + "enabled @bool = false\n" + ".transform\n" + " position @vec3 = -12 4.2 8\n" + " rotation @vec4 = 0 0 0 1\n" + " scale @vec3 = 1 1 1\n" + ".light_properties\n" + " radius @float = 8.5\n" + " color @vec3 = 1 0.02 0.05\n" + " intensity @float = 12\n"; } diff --git a/tests/prefab_instancing/01-format_and_handle.cpp b/tests/prefab_instancing/01-format_and_handle.cpp index e2ae96a7e..eb980ce2b 100644 --- a/tests/prefab_instancing/01-format_and_handle.cpp +++ b/tests/prefab_instancing/01-format_and_handle.cpp @@ -27,12 +27,11 @@ TOAST_TEST_NAMED("prefab_instancing", "prefab_instancing/01-format_and_handle", // --- A chunk's Prefab/UID fields survive a text round-trip -------------------------------- { - const char* text = - "~format @int = 2\n" - "\n" - "[instance type=toast::Node]\n" - "m_uid @uid = ABCDEFGHIJK\n" - "m_source_prefab @uid = LMNOPQRSTUV\n"; + const char* text = "~format @int = 2\n" + "\n" + "[instance type=toast::Node]\n" + "m_uid @uid = ABCDEFGHIJK\n" + "m_source_prefab @uid = LMNOPQRSTUV\n"; std::stringstream ss(text); Prefab nf(ss); @@ -45,7 +44,7 @@ TOAST_TEST_NAMED("prefab_instancing", "prefab_instancing/01-format_and_handle", // toFile re-emits the marker first, and the Prefab field survives re-parsing. std::string out = nf.toFile(); - assert(out.rfind("~format @int = 4", 0) == 0); // starts with the version marker + assert(out.rfind("~format @int = 5", 0) == 0); // starts with the version marker std::stringstream round(out); Prefab reparsed(round); @@ -84,10 +83,13 @@ TOAST_TEST_NAMED("prefab_instancing", "prefab_instancing/01-format_and_handle", assert(v3.nodes.size() == 1); // legacy version accepted Prefab v4(std::span(patched(4))); - assert(v4.nodes.size() == 1); // current version accepted + assert(v4.nodes.size() == 1); // legacy version accepted Prefab v5(std::span(patched(5))); - assert(v5.nodes.empty()); // future version refused + assert(v5.nodes.size() == 1); // current version accepted + + Prefab v6(std::span(patched(6))); + assert(v6.nodes.empty()); // future version refused } // --- validate() catches duplicate UIDs and multiple rootless chunks ----------------------