From ec62f278fa57bd383c47b8e84975dd3df2e7e76f Mon Sep 17 00:00:00 2001 From: Alexis Duburcq Date: Thu, 23 Jul 2026 09:06:25 +0200 Subject: [PATCH 01/14] Strip the madrona rigid-body physics simulation from the build Genesis runs its own physics and feeds transforms to the renderer, so madrona's rigid-body solvers are never part of the render task graph. Remove them from what gets built: - drop physics.cpp / xpbd / tgs / narrowphase / broadphase from the JIT megakernel sources (geo.cpp is kept, shared by the mesh BVH) - stop linking madrona_mw_physics / madrona_physics_loader into the bridge - drop the now-redundant physics.hpp / physics_loader.hpp includes (the bridge only used base components, which come from components.hpp) The mesh-BVH / geo / physics_assets infrastructure the raytracer depends on is kept. Dead source files and unused physics libs are deleted in a follow-up. --- src/bridge/CMakeLists.txt | 2 -- src/bridge/mgr.cpp | 1 - src/bridge/types.hpp | 1 - src/mw/CMakeLists.txt | 8 +++----- 4 files changed, 3 insertions(+), 9 deletions(-) diff --git a/src/bridge/CMakeLists.txt b/src/bridge/CMakeLists.txt index 07ade7ec..1a3b6077 100644 --- a/src/bridge/CMakeLists.txt +++ b/src/bridge/CMakeLists.txt @@ -11,7 +11,6 @@ target_link_libraries(madgs_cpu_impl madrona_mw_core PRIVATE madrona_common - madrona_mw_physics madrona_rendering_system ) @@ -37,7 +36,6 @@ target_link_libraries(madgs_mgr madrona_mw_cpu madrona_common madrona_importer - madrona_physics_loader madrona_render madrona_render_asset_processor ) diff --git a/src/bridge/mgr.cpp b/src/bridge/mgr.cpp index f5259aa9..843c626d 100644 --- a/src/bridge/mgr.cpp +++ b/src/bridge/mgr.cpp @@ -4,7 +4,6 @@ #include #include -#include #include #include #include diff --git a/src/bridge/types.hpp b/src/bridge/types.hpp index a5408bb3..cf19e641 100644 --- a/src/bridge/types.hpp +++ b/src/bridge/types.hpp @@ -3,7 +3,6 @@ #include #include #include -#include #include namespace madGS { diff --git a/src/mw/CMakeLists.txt b/src/mw/CMakeLists.txt index 2eaaed41..0bef14bd 100644 --- a/src/mw/CMakeLists.txt +++ b/src/mw/CMakeLists.txt @@ -82,12 +82,10 @@ set(TASK_GRAPH_DEVICE_SOURCES ${MADRONA_SRC_DIR}/common/hashmap.cpp ${MADRONA_SRC_DIR}/common/navmesh.cpp ${MADRONA_SRC_DIR}/core/base.cpp - ${MADRONA_SRC_DIR}/physics/physics.cpp + # Genesis drives physics itself and feeds transforms to the renderer, so the + # madrona rigid-body simulation is not part of the render task graph. Only + # geo.cpp is kept (shared geometry helpers used by the mesh BVH). ${MADRONA_SRC_DIR}/physics/geo.cpp - ${MADRONA_SRC_DIR}/physics/xpbd.cpp - ${MADRONA_SRC_DIR}/physics/tgs.cpp - ${MADRONA_SRC_DIR}/physics/narrowphase.cpp - ${MADRONA_SRC_DIR}/physics/broadphase.cpp ${MADRONA_SRC_DIR}/render/ecs_system.cpp ) From 49b2aa7bd5a86becd49bea9b2e8221b86933ddfa Mon Sep 17 00:00:00 2001 From: Alexis Duburcq Date: Thu, 23 Jul 2026 09:15:23 +0200 Subject: [PATCH 02/14] Drop dangling 'using namespace madrona::phys' from the bridge --- src/bridge/mgr.cpp | 1 - src/bridge/sim.cpp | 1 - 2 files changed, 2 deletions(-) diff --git a/src/bridge/mgr.cpp b/src/bridge/mgr.cpp index 843c626d..0b118e90 100644 --- a/src/bridge/mgr.cpp +++ b/src/bridge/mgr.cpp @@ -22,7 +22,6 @@ using namespace madrona; using namespace madrona::math; -using namespace madrona::phys; using namespace madrona::py; using namespace madrona::imp; diff --git a/src/bridge/sim.cpp b/src/bridge/sim.cpp index ad116609..cb9e7608 100644 --- a/src/bridge/sim.cpp +++ b/src/bridge/sim.cpp @@ -4,7 +4,6 @@ using namespace madrona; using namespace madrona::math; -using namespace madrona::phys; namespace RenderingSystem = madrona::render::RenderingSystem; From e2ad30eefc3085b50ec38a8e29467297591e2a15 Mon Sep 17 00:00:00 2001 From: Alexis Duburcq Date: Thu, 23 Jul 2026 09:31:33 +0200 Subject: [PATCH 03/14] Delete the madrona physics simulation and asset code Following the unlink, remove the now-unused physics sources entirely: the rigid-body solvers (xpbd, tgs, narrowphase, broadphase), physics.cpp, physics_assets and physics_loader, plus their headers, and drop the physics CMake subdirectory. The mesh-BVH builder no longer includes physics_assets (it only needs MeshBVH from mesh_bvh.hpp). Only the geometry helpers (geo.cpp, gjk.hpp) and the geo / mesh_bvh headers are kept -- the raytracer BVH needs them. --- include/madrona/broadphase.hpp | 119 -- include/madrona/broadphase.inl | 108 -- include/madrona/physics.hpp | 232 ---- include/madrona/physics.inl | 28 - include/madrona/physics_assets.hpp | 67 - include/madrona/physics_loader.hpp | 25 - src/CMakeLists.txt | 1 - src/common/mesh_bvh_builder.cpp | 1 - src/physics/CMakeLists.txt | 52 - src/physics/broadphase.cpp | 1054 --------------- src/physics/narrowphase.cpp | 1964 ---------------------------- src/physics/physics.cpp | 410 ------ src/physics/physics_assets.cpp | 1408 -------------------- src/physics/physics_impl.hpp | 60 - src/physics/physics_loader.cpp | 341 ----- src/physics/tgs.cpp | 304 ----- src/physics/tgs.hpp | 19 - src/physics/xpbd.cpp | 1146 ---------------- src/physics/xpbd.hpp | 19 - 19 files changed, 7358 deletions(-) delete mode 100644 include/madrona/broadphase.hpp delete mode 100644 include/madrona/broadphase.inl delete mode 100644 include/madrona/physics.hpp delete mode 100644 include/madrona/physics.inl delete mode 100644 include/madrona/physics_assets.hpp delete mode 100644 include/madrona/physics_loader.hpp delete mode 100644 src/physics/CMakeLists.txt delete mode 100644 src/physics/broadphase.cpp delete mode 100644 src/physics/narrowphase.cpp delete mode 100644 src/physics/physics.cpp delete mode 100644 src/physics/physics_assets.cpp delete mode 100644 src/physics/physics_impl.hpp delete mode 100644 src/physics/physics_loader.cpp delete mode 100644 src/physics/tgs.cpp delete mode 100644 src/physics/tgs.hpp delete mode 100644 src/physics/xpbd.cpp delete mode 100644 src/physics/xpbd.hpp diff --git a/include/madrona/broadphase.hpp b/include/madrona/broadphase.hpp deleted file mode 100644 index 2028fd96..00000000 --- a/include/madrona/broadphase.hpp +++ /dev/null @@ -1,119 +0,0 @@ -#pragma once - -#include -#include - -namespace madrona::phys { - -struct ObjectManager; - -} - -namespace madrona::phys::broadphase { - -struct LeafID { - int32_t id; -}; - -class BVH { -public: - BVH(const ObjectManager *obj_mgr, - CountT max_leaves, - float leaf_velocity_expansion, - float leaf_accel_expansion); - - inline LeafID reserveLeaf(Entity e, base::ObjectID obj_id); - inline math::AABB getLeafAABB(LeafID leaf_id) const; - - template - inline void findIntersecting(const math::AABB &aabb, Fn &&fn) const; - - template - inline void findLeafIntersecting(LeafID leaf_id, Fn &&fn) const; - - Entity traceRay(math::Vector3 o, - math::Vector3 d, - float *out_hit_t, - math::Vector3 *out_hit_normal, - float t_max = float(INFINITY)); - - void updateLeafPosition(LeafID leaf_id, - const math::Vector3 &pos, - const math::Quat &rot, - const math::Diag3x3 &scale, - const math::Vector3 &linear_vel, - const math::AABB &obj_aabb); - - math::AABB expandLeaf(LeafID leaf_id, - const math::Vector3 &linear_vel); - - void refitLeaf(LeafID leaf_id, const math::AABB &leaf_aabb); - - inline void rebuildOnUpdate(); - void updateTree(); - - inline void clearLeaves(); - -private: - static constexpr int32_t sentinel_ = 0xFFFF'FFFF_i32; - - struct Node { - float minX[4]; - float minY[4]; - float minZ[4]; - float maxX[4]; - float maxY[4]; - float maxZ[4]; - int32_t children[4]; - int32_t parentID; - - inline bool isLeaf(CountT child) const; - inline int32_t leafIDX(CountT child) const; - - inline void setLeaf(CountT child, int32_t idx); - inline void setInternal(CountT child, int32_t internal_idx); - inline bool hasChild(CountT child) const; - inline void clearChild(CountT child); - }; - - // FIXME: evaluate whether storing this in-line in the tree - // makes sense or if we should force a lookup through the entity ID - struct LeafTransform { - math::Vector3 pos; - math::Quat rot; - math::Diag3x3 scale; - }; - - inline CountT numInternalNodes(CountT num_leaves) const; - - void rebuild(); - void refit(LeafID *leaf_ids, CountT num_moved); - - bool traceRayIntoLeaf(int32_t leaf_idx, - math::Vector3 world_ray_o, - math::Vector3 world_ray_d, - float t_min, - float t_max, - float *hit_t, - math::Vector3 *hit_normal); - - Node *nodes_; - CountT num_nodes_; - const CountT num_allocated_nodes_; - Entity *leaf_entities_; - const ObjectManager *obj_mgr_; - base::ObjectID *leaf_obj_ids_; - math::AABB *leaf_aabbs_; // FIXME: remove this, it's duplicated data - LeafTransform *leaf_transforms_; - uint32_t *leaf_parents_; - int32_t *sorted_leaves_; - AtomicI32 num_leaves_; - int32_t num_allocated_leaves_; - float leaf_velocity_expansion_; - float leaf_accel_expansion_; - bool force_rebuild_; -}; - -} - -#include "broadphase.inl" diff --git a/include/madrona/broadphase.inl b/include/madrona/broadphase.inl deleted file mode 100644 index 494b35e1..00000000 --- a/include/madrona/broadphase.inl +++ /dev/null @@ -1,108 +0,0 @@ -namespace madrona::phys::broadphase { - -LeafID BVH::reserveLeaf(Entity e, base::ObjectID obj_id) -{ - int32_t leaf_idx = num_leaves_.fetch_add_relaxed(1); - assert(leaf_idx < num_allocated_leaves_); - - leaf_entities_[leaf_idx] = e; - leaf_obj_ids_[leaf_idx] = obj_id; - - return LeafID { - leaf_idx, - }; -} - -math::AABB BVH::getLeafAABB(LeafID leaf_id) const -{ - return leaf_aabbs_[leaf_id.id]; -} - -template -void BVH::findIntersecting(const math::AABB &aabb, Fn &&fn) const -{ - int32_t stack[32]; - stack[0] = 0; - CountT stack_size = 1; - - while (stack_size > 0) { - int32_t node_idx = stack[--stack_size]; - const Node &node = nodes_[node_idx]; - for (int i = 0; i < 4; i++) { - if (!node.hasChild(i)) { - continue; // Technically this could be break? - }; - - madrona::math::AABB child_aabb { - /* .pMin = */ { - node.minX[i], - node.minY[i], - node.minZ[i], - }, - /* .pMax = */ { - node.maxX[i], - node.maxY[i], - node.maxZ[i], - }, - }; - - if (aabb.overlaps(child_aabb)) { - if (node.isLeaf(i)) { - Entity e = leaf_entities_[node.leafIDX(i)]; - fn(e); - } else { - stack[stack_size++] = node.children[i]; - } - } - } - } -} - -template -void BVH::findLeafIntersecting(LeafID leaf_id, Fn &&fn) const -{ - math::AABB leaf_aabb = leaf_aabbs_[leaf_id.id]; - findIntersecting(leaf_aabb, std::forward(fn)); -} - -void BVH::rebuildOnUpdate() -{ - force_rebuild_ = true; -} - -void BVH::clearLeaves() -{ - num_leaves_.store_relaxed(0); -} - -bool BVH::Node::isLeaf(CountT child) const -{ - return children[child] & 0x80000000; -} - -int32_t BVH::Node::leafIDX(CountT child) const -{ - return children[child] & ~0x80000000; -} - -void BVH::Node::setLeaf(CountT child, int32_t idx) -{ - children[child] = 0x80000000 | idx; -} - -void BVH::Node::setInternal(CountT child, int32_t internal_idx) -{ - children[child] = internal_idx; -} - -bool BVH::Node::hasChild(CountT child) const -{ - return children[child] != sentinel_; -} - -void BVH::Node::clearChild(CountT child) -{ - children[child] = sentinel_; -} - -} diff --git a/include/madrona/physics.hpp b/include/madrona/physics.hpp deleted file mode 100644 index 1fbee69c..00000000 --- a/include/madrona/physics.hpp +++ /dev/null @@ -1,232 +0,0 @@ -#pragma once -#include -#include -#include -#include -#include - -#include -#include - -namespace madrona::phys { - -struct ExternalForce : math::Vector3 { - ExternalForce(math::Vector3 v) - : Vector3(v) - {} -}; - -struct ExternalTorque : math::Vector3 { - ExternalTorque(math::Vector3 v) - : Vector3(v) - {} -}; - -enum class ResponseType : uint32_t { - Dynamic, - Kinematic, - Static, -}; - -struct Velocity { - math::Vector3 linear; - math::Vector3 angular; -}; - -struct SolverBundleAlias {}; - -struct RigidBody : Bundle< - base::ObjectInstance, - ResponseType, - broadphase::LeafID, - Velocity, - ExternalForce, - ExternalTorque, - SolverBundleAlias -> {}; - -struct CandidateCollision { - Loc a; - Loc b; - uint32_t aPrim; - uint32_t bPrim; -}; - -struct ContactConstraint { - Loc ref; - Loc alt; - math::Vector4 points[4]; - int32_t numPoints; - math::Vector3 normal; -}; - -struct JointConstraint { - enum class Type { - Fixed, - Hinge - }; - - struct Fixed { - math::Quat attachRot1; - math::Quat attachRot2; - float separation; - }; - - struct Hinge { - math::Vector3 a1Local; - math::Vector3 a2Local; - math::Vector3 b1Local; - math::Vector3 b2Local; - }; - - Entity e1; - Entity e2; - Type type; - - union { - Fixed fixed; - Hinge hinge; - }; - - math::Vector3 r1; - math::Vector3 r2; -}; - -struct CollisionEvent { - Entity a; - Entity b; -}; - -struct CollisionEventTemporary : Archetype {}; - -// Per object state -struct RigidBodyMassData { - float invMass; - math::Vector3 invInertiaTensor; - math::Vector3 toCenterOfMass; - math::Quat toInteriaFrame; -}; - -struct RigidBodyFrictionData { - float muS; - float muD; -}; - -struct RigidBodyMetadata { - RigidBodyMassData mass; - RigidBodyFrictionData friction; -}; - -struct CollisionPrimitive { - enum class Type : uint32_t { - Sphere = 1 << 0, - Hull = 1 << 1, - Plane = 1 << 2, - }; - - struct Sphere { - float radius; - }; - - struct Hull { - geo::HalfEdgeMesh halfEdgeMesh; - }; - - struct Plane {}; - - Type type; - union { - Sphere sphere; - Plane plane; - Hull hull; - }; -}; - -struct ObjectManager { - CollisionPrimitive *collisionPrimitives; - math::AABB *primitiveAABBs; - - math::AABB *rigidBodyAABBs; - uint32_t *rigidBodyPrimitiveOffsets; - uint32_t *rigidBodyPrimitiveCounts; - RigidBodyMetadata *metadata; -}; - -struct ObjectData { - ObjectManager *mgr; -}; - -namespace PhysicsSystem { - enum class Solver : uint32_t { - XPBD, - TGS, - }; - - void init(Context &ctx, - ObjectManager *obj_mgr, - float delta_t, - CountT num_substeps, - math::Vector3 gravity, - CountT max_dynamic_objects, - Solver solver = Solver::XPBD); - - void reset(Context &ctx); - broadphase::LeafID registerEntity(Context &ctx, - Entity e, - base::ObjectID obj_id); - - template - void findEntitiesWithinAABB(Context &ctx, - math::AABB aabb, - Fn &&fn); - - bool checkEntityAABBOverlap(Context &ctx, - math::AABB aabb, - Entity e); - - Entity makeFixedJoint(Context &ctx, - Entity e1, Entity e2, - math::Quat attach_rot1, math::Quat attach_rot2, - math::Vector3 r1, math::Vector3 r2, - float separation); - - Entity makeHingeJoint(Context &ctx, - Entity e1, Entity e2, - math::Vector3 a1_local, math::Vector3 a2_local, - math::Vector3 b1_local, math::Vector3 b2_local, - math::Vector3 r1, math::Vector3 r2); - - - void registerTypes(ECSRegistry ®istry, - Solver solver = Solver::XPBD); - - TaskGraphNodeID setupBroadphaseTasks( - TaskGraphBuilder &builder, - Span deps); - - TaskGraphNodeID setupPhysicsStepTasks( - TaskGraphBuilder &builder, - Span deps, - CountT num_substeps, - Solver solver = Solver::XPBD); - - TaskGraphNodeID setupCleanupTasks( - TaskGraphBuilder &builder, - Span deps); - - // Use the below two functions if you just want to use the broadphase without - // the rest of the physics system - - TaskGraphNodeID setupStandaloneBroadphaseOverlapTasks( - TaskGraphBuilder &builder, - Span deps); - - TaskGraphNodeID setupStandaloneBroadphaseCleanupTasks( - TaskGraphBuilder &builder, - Span deps); - -}; - -} - -#include "physics.inl" diff --git a/include/madrona/physics.inl b/include/madrona/physics.inl deleted file mode 100644 index b0654029..00000000 --- a/include/madrona/physics.inl +++ /dev/null @@ -1,28 +0,0 @@ -#pragma once - -namespace madrona::phys { - -namespace PhysicsSystem { - -template -void findEntitiesWithinAABB(Context &ctx, - math::AABB aabb, - Fn &&fn) -{ - using namespace madrona::base; - using namespace madrona::math; - - auto &bvh = ctx.singleton(); - - bvh.findIntersecting(aabb, [&](Entity e) { - bool overlap = checkEntityAABBOverlap( - ctx, aabb, e); - if (overlap) { - fn(e); - } - }); -} - -} - -} diff --git a/include/madrona/physics_assets.hpp b/include/madrona/physics_assets.hpp deleted file mode 100644 index d88954b7..00000000 --- a/include/madrona/physics_assets.hpp +++ /dev/null @@ -1,67 +0,0 @@ -#pragma once - -#include -#include -#include -#include - -#include - -namespace madrona::phys { - -struct SourceCollisionPrimitive { - struct HullInput { - uint32_t hullIDX; - }; - - CollisionPrimitive::Type type; - union { - CollisionPrimitive::Sphere sphere; - CollisionPrimitive::Plane plane; - HullInput hullInput; - }; -}; - -struct SourceCollisionObject { - Span prims; - float invMass; - RigidBodyFrictionData friction; -}; - -struct RigidBodyAssets { - struct HullData { - geo::HalfEdge *halfEdges; - uint32_t *faceBaseHalfEdges; - geo::Plane *facePlanes; - math::Vector3 *vertices; - - uint32_t numHalfEdges; - uint32_t numFaces; - uint32_t numVerts; - } hullData; - - // Per Primitive Data - CollisionPrimitive *primitives; - math::AABB *primitiveAABBs; - - // Per Object Data - RigidBodyMetadata *metadatas; - math::AABB *objAABBs; - uint32_t *primOffsets; - uint32_t *primCounts; - - uint32_t numConvexHulls; - uint32_t totalNumPrimitives; - uint32_t numObjs; - - static void * processRigidBodyAssets( - Span convex_hull_meshes, - Span collision_objs, - bool build_convex_hulls, - StackAlloc &tmp_alloc, - RigidBodyAssets *out_assets, - CountT *out_num_bytes); -}; - - -} diff --git a/include/madrona/physics_loader.hpp b/include/madrona/physics_loader.hpp deleted file mode 100644 index 0e7c84a8..00000000 --- a/include/madrona/physics_loader.hpp +++ /dev/null @@ -1,25 +0,0 @@ -#pragma once - -#include -#include - -#include - -namespace madrona::phys { - -class PhysicsLoader { -public: - PhysicsLoader(ExecMode exec_mode, CountT max_objects); - ~PhysicsLoader(); - PhysicsLoader(PhysicsLoader &&o); - - CountT loadRigidBodies(const RigidBodyAssets &assets); - - ObjectManager & getObjectManager(); - -private: - struct Impl; - std::unique_ptr impl_; -}; - -} diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 6f70ecee..d98501cf 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -2,7 +2,6 @@ add_subdirectory(common) add_subdirectory(core) add_subdirectory(importer) -add_subdirectory(physics) add_subdirectory(render) diff --git a/src/common/mesh_bvh_builder.cpp b/src/common/mesh_bvh_builder.cpp index 3907f4bf..30ed7780 100644 --- a/src/common/mesh_bvh_builder.cpp +++ b/src/common/mesh_bvh_builder.cpp @@ -1,6 +1,5 @@ #include -#include #include #include diff --git a/src/physics/CMakeLists.txt b/src/physics/CMakeLists.txt deleted file mode 100644 index cb7ac659..00000000 --- a/src/physics/CMakeLists.txt +++ /dev/null @@ -1,52 +0,0 @@ -set(INC_DIR "${CMAKE_CURRENT_SOURCE_DIR}/../../include/madrona") - -set(MADRONA_PHYSICS_SRCS - ${INC_DIR}/physics.hpp ${INC_DIR}/physics.inl physics.cpp - ${INC_DIR}/mesh_bvh.hpp ${INC_DIR}/mesh_bvh.inl - ${INC_DIR}/geo.hpp ${INC_DIR}/geo.inl geo.cpp - narrowphase.cpp broadphase.cpp - xpbd.hpp xpbd.cpp - tgs.hpp tgs.cpp -) - -add_library(madrona_physics STATIC - ${MADRONA_PHYSICS_SRCS} -) - -target_link_libraries(madrona_physics - PUBLIC - madrona_core -) - -add_library(madrona_mw_physics STATIC - ${MADRONA_PHYSICS_SRCS} -) - -target_link_libraries(madrona_mw_physics - PUBLIC - madrona_mw_core -) - -add_library(madrona_physics_assets STATIC - ${INC_DIR}/physics_assets.hpp physics_assets.cpp -) - -target_link_libraries(madrona_physics_assets PRIVATE - madrona_common -) - -add_library(madrona_physics_loader STATIC - ${INC_DIR}/physics_loader.hpp physics_loader.cpp -) - -target_link_libraries(madrona_physics_loader - PRIVATE - madrona_common - PUBLIC - madrona_physics_assets -) - -if (TARGET madrona_cuda) - target_link_libraries(madrona_physics_loader PRIVATE - madrona_cuda) -endif () diff --git a/src/physics/broadphase.cpp b/src/physics/broadphase.cpp deleted file mode 100644 index 77cb1fe3..00000000 --- a/src/physics/broadphase.cpp +++ /dev/null @@ -1,1054 +0,0 @@ -#include -#include - -#include - -#include "physics_impl.hpp" - -namespace madrona::phys::broadphase { - -using namespace base; -using namespace math; -using namespace geo; - -BVH::BVH(const ObjectManager *obj_mgr, - CountT max_leaves, - float leaf_velocity_expansion, - float leaf_accel_expansion) - : nodes_((Node *)rawAlloc(sizeof(Node) * - numInternalNodes(max_leaves))), - num_nodes_(0), - num_allocated_nodes_(numInternalNodes(max_leaves)), - leaf_entities_((Entity *)rawAlloc(sizeof(Entity) * max_leaves)), - obj_mgr_(obj_mgr), // FIXME, get rid of this - leaf_obj_ids_((ObjectID *) - rawAlloc(sizeof(ObjectID) * max_leaves)), - leaf_aabbs_((AABB *)rawAlloc(sizeof(AABB) * max_leaves)), - leaf_transforms_( - (LeafTransform *)rawAlloc(sizeof(LeafTransform) * max_leaves)), - leaf_parents_((uint32_t *)rawAlloc(sizeof(uint32_t) * max_leaves)), - sorted_leaves_((int32_t *)rawAlloc(sizeof(int32_t) * max_leaves)), - num_leaves_(0), - num_allocated_leaves_(max_leaves), - leaf_velocity_expansion_(leaf_velocity_expansion), - leaf_accel_expansion_(leaf_accel_expansion), - force_rebuild_(true) -{} - -CountT BVH::numInternalNodes(CountT num_leaves) const -{ - return std::max(utils::divideRoundUp(num_leaves - 1, CountT(3)), CountT(1)) + - num_leaves; // + num_leaves should not be necessary but the current - // top down build has an issue where leaves get - // unnecessarily split amongst internal nodes with only - // 1 or 2 children -} - -void BVH::rebuild() -{ - int32_t num_internal_nodes = numInternalNodes(num_leaves_.load_relaxed()); - num_nodes_ = num_internal_nodes; - assert(num_nodes_ <= num_allocated_nodes_); - - struct StackEntry { - int32_t nodeID; - int32_t parentID; - int32_t offset; - int32_t numObjs; - }; - - StackEntry stack[64]; - stack[0] = StackEntry { - sentinel_, - sentinel_, - 0, - int32_t(num_leaves_.load_relaxed()), - }; - - int32_t cur_node_offset = 0; - CountT stack_size = 1; - - while (stack_size > 0) { - StackEntry &entry = stack[stack_size - 1]; - int32_t node_id; - if (entry.numObjs <= 4) { - node_id = cur_node_offset++; - - Node &node = nodes_[node_id]; - node.parentID = entry.parentID; - - for (int i = 0; i < 4; i++) { - if (i < entry.numObjs) { - int32_t leaf_id = sorted_leaves_[entry.offset + i]; - - const auto &aabb = leaf_aabbs_[leaf_id]; - leaf_parents_[leaf_id] = - ((uint32_t)node_id << 2) | (uint32_t)i; - - node.setLeaf(i, leaf_id); - node.minX[i] = aabb.pMin.x; - node.minY[i] = aabb.pMin.y; - node.minZ[i] = aabb.pMin.z; - node.maxX[i] = aabb.pMax.x; - node.maxY[i] = aabb.pMax.y; - node.maxZ[i] = aabb.pMax.z; - } else { - node.clearChild(i); - node.minX[i] = FLT_MAX; - node.minY[i] = FLT_MAX; - node.minZ[i] = FLT_MAX; - node.maxX[i] = -FLT_MAX; - node.maxY[i] = -FLT_MAX; - node.maxZ[i] = -FLT_MAX; - } - } - } else if (entry.nodeID == sentinel_) { - node_id = cur_node_offset++; - // Record the node id in the stack entry for when this entry - // is reprocessed - entry.nodeID = node_id; - - Node &node = nodes_[node_id]; - for (CountT i = 0; i < 4; i++) { - node.clearChild(i); - } - node.parentID = entry.parentID; - - // midpoint sort items - auto midpoint_split = [this]( - int32_t base, int32_t num_elems) { - - auto get_center = [this, base](int32_t offset) { - AABB aabb = leaf_aabbs_[sorted_leaves_[base + offset]]; - - return (aabb.pMin + aabb.pMax) / 2.f; - }; - - Vector3 center_min { - FLT_MAX, - FLT_MAX, - FLT_MAX, - }; - - Vector3 center_max { - -FLT_MAX, - -FLT_MAX, - -FLT_MAX, - }; - - for (int i = 0; i < num_elems; i++) { - const Vector3 ¢er = get_center(i); - center_min = Vector3::min(center_min, center); - center_max = Vector3::max(center_max, center); - } - - auto split = [&](auto get_component) { - float split_val = 0.5f * (get_component(center_min) + - get_component(center_max)); - - int start = 0; - int end = num_elems; - - while (start < end) { - while (start < end && - get_component(get_center(start)) < split_val) { - ++start; - } - - while (start < end && get_component( - get_center(end - 1)) >= split_val) { - --end; - } - - if (start < end) { - std::swap(sorted_leaves_[base + start], - sorted_leaves_[base + end - 1]); - ++start; - --end; - } - } - - if (start > 0 && start < num_elems) { - return start; - } else { - return num_elems / 2; - } - }; - - Vector3 center_diff = center_max - center_min; - if (center_diff.x > center_diff.y && - center_diff.x > center_diff.z) { - return split([](Vector3 v) { - return v.x; - }); - } else if (center_diff.y > center_diff.x && - center_diff.y > center_diff.z) { - return split([](Vector3 v) { - return v.y; - }); - } else { - return split([](Vector3 v) { - return v.z; - }); - } - }; - - int32_t second_split = midpoint_split(entry.offset, entry.numObjs); - int32_t num_h1 = second_split; - int32_t num_h2 = entry.numObjs - second_split; - - int32_t first_split = midpoint_split(entry.offset, num_h1); - int32_t third_split = - midpoint_split(entry.offset + second_split, num_h2); - - // Setup stack to recurse into fourths. Put fourths on stack in - // reverse order to preserve left-right depth first ordering - - stack[stack_size++] = { - -1, - entry.nodeID, - entry.offset + num_h1 + third_split, - num_h2 - third_split, - }; - - stack[stack_size++] = { - -1, - entry.nodeID, - entry.offset + num_h1, - third_split, - }; - - stack[stack_size++] = { - -1, - entry.nodeID, - entry.offset + first_split, - num_h1 - first_split, - }; - - stack[stack_size++] = { - -1, - entry.nodeID, - entry.offset, - first_split, - }; - - // Don't finish processing this node until children are processed - continue; - } else { - // Revisiting this node after having processed children - node_id = entry.nodeID; - } - - // At this point, remove the current entry from the stack - stack_size -= 1; - - Node &node = nodes_[node_id]; - if (node.parentID == -1) { - continue; - } - - AABB combined_aabb = AABB::invalid(); - for (CountT i = 0; i < 4; i++) { - if (!node.hasChild(i)) { - break; - } - - combined_aabb = AABB::merge(combined_aabb, AABB { - /* .pMin = */ { - node.minX[i], - node.minY[i], - node.minZ[i], - }, - /* .pMax = */ { - node.maxX[i], - node.maxY[i], - node.maxZ[i], - }, - }); - } - - Node &parent = nodes_[node.parentID]; - CountT child_offset; - for (child_offset = 0; ; child_offset++) { - if (parent.children[child_offset] == sentinel_) { - break; - } - } - - parent.setInternal(child_offset, node_id); - parent.minX[child_offset] = combined_aabb.pMin.x; - parent.minY[child_offset] = combined_aabb.pMin.y; - parent.minZ[child_offset] = combined_aabb.pMin.z; - parent.maxX[child_offset] = combined_aabb.pMax.x; - parent.maxY[child_offset] = combined_aabb.pMax.y; - parent.maxZ[child_offset] = combined_aabb.pMax.z; - } - -#if 0 - { - // validate tree bottom up - int32_t num_leaves = num_leaves_.load_relaxed(); - for (int32_t i = 0; i < num_leaves; i++) { - const AABB &leaf_aabb = leaf_aabbs_[i]; - uint32_t leaf_parent = leaf_parents_[i]; - - int32_t node_idx = int32_t(leaf_parent >> 2_u32); - int32_t sub_idx = int32_t(leaf_parent & 3); - - Node *node = &nodes_[node_idx]; - while (true) { - auto invalid = [&]() { - printf("%d %d %d\n\t(%f %f %f) (%f %f %f)\n\t(%f %f %f) (%f %f %f)\n", - i, node_idx, sub_idx, leaf_aabb.pMin.x, leaf_aabb.pMin.y, leaf_aabb.pMin.z, - leaf_aabb.pMax.x, leaf_aabb.pMax.y, leaf_aabb.pMax.z, - node->minX[sub_idx], node->minY[sub_idx], node->minZ[sub_idx], - node->maxX[sub_idx], node->maxY[sub_idx], node->maxZ[sub_idx]); - assert(false); - }; - - if (leaf_aabb.pMin.x < node->minX[sub_idx]) { - invalid(); - } - if (leaf_aabb.pMin.y < node->minY[sub_idx]) { - invalid(); - } - if (leaf_aabb.pMin.z < node->minZ[sub_idx]) { - invalid(); - } - - if (leaf_aabb.pMax.x > node->maxX[sub_idx]) { - invalid(); - } - if (leaf_aabb.pMax.y > node->maxY[sub_idx]) { - invalid(); - } - if (leaf_aabb.pMax.z > node->maxZ[sub_idx]) { - invalid(); - } - - int child_idx = node_idx; - node_idx = node->parentID; - if (node_idx == sentinel_) { - break; - } - - node = &nodes_[node_idx]; - - int child_offset = -1; - for (int j = 0; j < 4; j++) { - if (node->children[j] == child_idx) { - child_offset = j; - break; - } - } - sub_idx = child_offset; - }; - } - } - - // Validate top down - { - int32_t stack[128]; - AABB aabb_stack[128]; - stack[0] = 0; - aabb_stack[0] = { - -FLT_MAX, - -FLT_MAX, - -FLT_MAX, - FLT_MAX, - FLT_MAX, - FLT_MAX, - }; - CountT stack_size = 1; - - while (stack_size > 0) { - int32_t stack_idx = --stack_size; - int32_t node_idx = stack[stack_idx]; - madrona::math::AABB parent_aabb = aabb_stack[stack_idx]; - - const Node &node = nodes_[node_idx]; - for (int i = 0; i < 4; i++) { - if (!node.hasChild(i)) { - continue; // Technically this could be break? - } - - AABB child_aabb { - /* .pMin = */ { - node.minX[i], - node.minY[i], - node.minZ[i], - }, - /* .pMax = */ { - node.maxX[i], - node.maxY[i], - node.maxZ[i], - }, - }; - - auto invalid = [&]() { - printf("Invalid top down %d %d (%f %f %f) (%f %f %f) (%f %f %f) (%f %f %f)\n", - node_idx, i, - parent_aabb.pMin.x, parent_aabb.pMin.y, parent_aabb.pMin.z, - parent_aabb.pMax.x, parent_aabb.pMax.y, parent_aabb.pMax.z, - child_aabb.pMin.x, child_aabb.pMin.y, child_aabb.pMin.z, - child_aabb.pMax.x, child_aabb.pMax.y, child_aabb.pMax.z); - assert(false); - }; - - if (child_aabb.pMin.x < parent_aabb.pMin.x) { - invalid(); - } - - if (child_aabb.pMin.y < parent_aabb.pMin.y) { - invalid(); - } - - if (child_aabb.pMin.z < parent_aabb.pMin.z) { - invalid(); - } - - if (child_aabb.pMax.x > parent_aabb.pMax.x) { - invalid(); - } - - if (child_aabb.pMax.y > parent_aabb.pMax.y) { - invalid(); - } - - if (child_aabb.pMax.z > parent_aabb.pMax.z) { - invalid(); - } - - if (node.isLeaf(i)) { - int32_t leaf_idx = node.leafIDX(i); - if (leaf_idx >= num_leaves_.load_relaxed()) { - printf("Out of bounds leaf %u %u %u\n", - i, leaf_idx, num_leaves_.load_relaxed()); - assert(false); - } - } else { - stack[stack_size] = node.children[i]; - aabb_stack[stack_size] = child_aabb; - stack_size += 1; - } - } - } - } -#endif -} - -static inline AABB expandAABBWithMotion( - AABB aabb, - const Vector3 &linear_velocity, - float velocity_expansion_factor, - float accel_expansion_factor) -{ - // FIXME include external velocity -#pragma unroll - for (int32_t i = 0; i < 3; i++) { - float pos_delta = - velocity_expansion_factor * linear_velocity[i]; - - float min_delta = pos_delta - accel_expansion_factor; - float max_delta = pos_delta + accel_expansion_factor; - - if (min_delta < 0.f) { - aabb.pMin[i] += min_delta; - } - if (max_delta > 0.f) { - aabb.pMax[i] += max_delta; - } - } - - return aabb; -} - -void BVH::updateLeafPosition(LeafID leaf_id, - const Vector3 &pos, - const Quat &rot, - const Diag3x3 &scale, - const Vector3 &linear_vel, - const AABB &obj_aabb) -{ - AABB world_aabb = obj_aabb.applyTRS(pos, rot, scale); - AABB expanded_aabb = expandAABBWithMotion(world_aabb, linear_vel, - leaf_velocity_expansion_, - leaf_accel_expansion_); - - leaf_aabbs_[leaf_id.id] = expanded_aabb; - leaf_transforms_[leaf_id.id] = { - pos, - rot, - scale, - }; - sorted_leaves_[leaf_id.id] = leaf_id.id; -} - -AABB BVH::expandLeaf(LeafID leaf_id, - const math::Vector3 &linear_vel) -{ - AABB aabb = leaf_aabbs_[leaf_id.id]; - AABB expanded_aabb = expandAABBWithMotion(aabb, linear_vel, - leaf_velocity_expansion_, leaf_accel_expansion_); - - leaf_aabbs_[leaf_id.id] = expanded_aabb; - - return expanded_aabb; -} - -MADRONA_ALWAYS_INLINE static inline float atomicMinF(float *addr, float value) -{ -#ifdef MADRONA_GPU_MODE - float old; - if (!signbit(value)) { - old = __int_as_float(atomicMin((int *)addr, __float_as_int(value))); - } else { - old = __uint_as_float( - atomicMax((unsigned int *)addr, __float_as_uint(value))); - } - - return old; -#else - AtomicFloatRef a(*addr); - float old = a.load(); - - while (old > value && - !a.compare_exchange_weak(old, value)) - {} - - return old; -#endif -} - -MADRONA_ALWAYS_INLINE static inline float atomicMaxF(float *addr, float value) -{ -#ifdef MADRONA_GPU_MODE - float old; - - // cuda::atomic::fetch_max does not seem to work properly (cuda 11.8) - if (!signbit(value)) { - old = __int_as_float( - atomicMax((int *)addr, __float_as_int(value))); - } else { - old = __uint_as_float( - atomicMin((unsigned int *)addr, __float_as_uint(value))); - } - - return old; -#else - AtomicFloatRef a(*addr); - float old = a.load(); - - while (old < value && - !a.compare_exchange_weak(old, value)) - {} - - return old; -#endif -} - -void BVH::refitLeaf(LeafID leaf_id, const AABB &leaf_aabb) -{ - uint32_t leaf_parent = leaf_parents_[leaf_id.id]; - - int32_t node_idx = int32_t(leaf_parent >> 2_u32); - int32_t sub_idx = int32_t(leaf_parent & 3); - - Node &leaf_node = nodes_[node_idx]; - - { - auto nonAtomicMinF = [](float *ptr, float v) { - AtomicFloatRef a(*ptr); - float old = a.load(); - if (v < old) { - a.store(v); - } - return old; - }; - - auto nonAtomicMaxF = [](float *ptr, float v) { - AtomicFloatRef a(*ptr); - float old = a.load(); - if (v > old) { - a.store(v); - } - return old; - }; - - float x_min_prev = - nonAtomicMinF(&leaf_node.minX[sub_idx], leaf_aabb.pMin.x); - float y_min_prev = - nonAtomicMinF(&leaf_node.minY[sub_idx], leaf_aabb.pMin.y); - float z_min_prev = - nonAtomicMinF(&leaf_node.minZ[sub_idx], leaf_aabb.pMin.z); - float x_max_prev = - nonAtomicMaxF(&leaf_node.maxX[sub_idx], leaf_aabb.pMax.x); - float y_max_prev = - nonAtomicMaxF(&leaf_node.maxY[sub_idx], leaf_aabb.pMax.y); - float z_max_prev = - nonAtomicMaxF(&leaf_node.maxZ[sub_idx], leaf_aabb.pMax.z); - - bool expanded = leaf_aabb.pMin.x < x_min_prev || - leaf_aabb.pMin.y < y_min_prev || - leaf_aabb.pMin.z < z_min_prev || - leaf_aabb.pMax.x > x_max_prev || - leaf_aabb.pMax.y > y_max_prev || - leaf_aabb.pMax.z > z_max_prev; - - if (!expanded) return; - } - - int32_t child_idx = node_idx; - node_idx = leaf_node.parentID; - - while (node_idx != sentinel_) { - Node &node = nodes_[node_idx]; - int child_offset = -1; - for (int j = 0; j < 4; j++) { - if (node.children[j] == child_idx) { - child_offset = j; - break; - } - } - assert(child_offset != -1); - - float x_min_prev = - atomicMinF(&node.minX[child_offset], leaf_aabb.pMin.x); - - float y_min_prev = - atomicMinF(&node.minY[child_offset], leaf_aabb.pMin.y); - - float z_min_prev = - atomicMinF(&node.minZ[child_offset], leaf_aabb.pMin.z); - - float x_max_prev = - atomicMaxF(&node.maxX[child_offset], leaf_aabb.pMax.x); - - float y_max_prev = - atomicMaxF(&node.maxY[child_offset], leaf_aabb.pMax.y); - - float z_max_prev = - atomicMaxF(&node.maxZ[child_offset], leaf_aabb.pMax.z); - - bool expanded = leaf_aabb.pMin.x < x_min_prev || - leaf_aabb.pMin.y < y_min_prev || - leaf_aabb.pMin.z < z_min_prev || - leaf_aabb.pMax.x > x_max_prev || - leaf_aabb.pMax.y > y_max_prev || - leaf_aabb.pMax.z > z_max_prev; - - if (!expanded) { - break; - } - - child_idx = node_idx; - node_idx = node.parentID; - } -} - -void BVH::updateTree() -{ - if (force_rebuild_) { - force_rebuild_ = false; - rebuild(); - } - //rebuild(); -} - -Entity BVH::traceRay(Vector3 o, - Vector3 d, - float *out_hit_t, - Vector3 *out_hit_normal, - float t_max) -{ - using namespace math; - - Diag3x3 inv_d = Diag3x3::fromVec(d).inv(); - - int32_t stack[32]; - stack[0] = 0; - CountT stack_size = 1; - - Entity closest_hit_entity = Entity::none(); - Vector3 closest_hit_normal; - - while (stack_size > 0) { - int32_t node_idx = stack[--stack_size]; - const Node &node = nodes_[node_idx]; - for (int i = 0; i < 4; i++) { - if (!node.hasChild(i)) { - continue; // Technically this could be break? - }; - - madrona::math::AABB child_aabb { - /* .pMin = */ { - node.minX[i], - node.minY[i], - node.minZ[i], - }, - /* .pMax = */ { - node.maxX[i], - node.maxY[i], - node.maxZ[i], - }, - }; - - if (child_aabb.rayIntersects(o, inv_d, 0.f, t_max)) { - if (node.isLeaf(i)) { - int32_t leaf_idx = node.leafIDX(i); - - float hit_t; - Vector3 leaf_hit_normal; - bool leaf_hit = traceRayIntoLeaf( - leaf_idx, o, d, 0.f, t_max, &hit_t, &leaf_hit_normal); - - if (leaf_hit) { - t_max = hit_t; - closest_hit_entity = leaf_entities_[leaf_idx]; - closest_hit_normal = leaf_hit_normal; - } - } else { - stack[stack_size++] = node.children[i]; - } - } - } - } - - if (closest_hit_entity == Entity::none()) { - return Entity::none(); - } - - *out_hit_t = t_max; - *out_hit_normal = closest_hit_normal; - return closest_hit_entity; -} - -static inline bool traceRayIntoPlane( - Vector3 ray_o, Vector3 ray_d, - float t_min, float t_max, - float *hit_t, - Vector3 *hit_normal) -{ - // ray_o and ray_d have already been transformed into the space of the - // plane. normal is (0, 0, 1), d is 0 - - float denom = ray_d.z; - - if (denom == 0) { - return false; - } - - float t = -ray_o.z / denom; - - if (t < t_min || t > t_max) { - return false; - } - - *hit_t = t; - *hit_normal = Vector3 { 0, 0, 1 }; - return true; -} - -// RTCD 5.3.8 (modified from segment to ray). Algorithm also in GPU Gems 2. -// Intersect ray r(t)=ray_o + , t_min <= t <=t_max against convex polyhedron -// specified by the n halfspaces defined by the planes p[]. On exit tfirst -// and tlast define the intersection, if any -static inline bool traceRayIntoConvexPolyhedron( - const HalfEdgeMesh &convex_mesh, - Vector3 ray_o, Vector3 ray_d, - float t_min, float t_max, - float *hit_t, - Vector3 *hit_normal) -{ - // Set initial interval based on t_min & t_max. For a ray, tlast should be - // set to +FLT_MAX. For a line, tfirst should also be set to –FLT_MAX - float tfirst = t_min; - float tlast = t_max; - - // Intersect segment against each plane - const CountT num_faces = convex_mesh.numFaces; - - // Our face normals point outside. RTCD uses plane normals pointing inside - // the polyhedron, so signs are flipped relative to the book - - Vector3 closest_normal = Vector3::zero(); - - for (CountT face_idx = 0; face_idx < num_faces; face_idx++) { - Plane plane = convex_mesh.facePlanes[face_idx]; - - float denom = dot(plane.normal, ray_d); - float neg_dist = plane.d - dot(plane.normal, ray_o); - - // Test if segment runs parallel to the plane - if (denom == 0.0f) { - // If so, return “no intersection” if segment lies outside plane - if (neg_dist < 0.0f) return false; - } else { - // Compute parameterized t value for intersection with current plane - float t = neg_dist / denom; - if (denom < 0.0f) { - // When entering halfspace, update tfirst if t is larger - if (t >= tfirst) { - tfirst = t; - closest_normal = plane.normal; - } - } else { - // When exiting halfspace, update tlast if t is smaller - if (t <= tlast) { - tlast = t; - } - } - // Exit with “no intersection” if intersection becomes empty - if (tfirst > tlast) return false; - } - } - - // Addition from RTCD algo: if ray only hits backfacing planes - // we don't set closest_normal and treat this as a miss - if (closest_normal.x == 0 && closest_normal.y == 0 && - closest_normal.z == 0) { - return false; - } - - *hit_t = tfirst; - *hit_normal = closest_normal; - - return true; -} - -bool BVH::traceRayIntoLeaf(int32_t leaf_idx, - math::Vector3 world_ray_o, - math::Vector3 world_ray_d, - float t_min, - float t_max, - float *hit_t, - math::Vector3 *hit_normal) -{ - ObjectID obj_id = leaf_obj_ids_[leaf_idx]; - LeafTransform leaf_txfm = leaf_transforms_[leaf_idx]; - - Quat rot_to_local = leaf_txfm.rot.inv(); - - Vector3 obj_ray_o = rot_to_local.rotateVec(world_ray_o - leaf_txfm.pos); - obj_ray_o.x /= leaf_txfm.scale.d0; - obj_ray_o.y /= leaf_txfm.scale.d1; - obj_ray_o.z /= leaf_txfm.scale.d2; - - Vector3 obj_ray_d = leaf_txfm.rot.inv().rotateVec(world_ray_d); - obj_ray_d.x /= leaf_txfm.scale.d0; - obj_ray_d.y /= leaf_txfm.scale.d1; - obj_ray_d.z /= leaf_txfm.scale.d2; - - auto inv_obj_ray_d = Diag3x3::fromVec(1.f / obj_ray_d); - - Vector3 obj_hit_normal; - - CountT prim_offset = obj_mgr_->rigidBodyPrimitiveOffsets[obj_id.idx]; - CountT num_prims = obj_mgr_->rigidBodyPrimitiveCounts[obj_id.idx]; - - bool hit_leaf = false; - for (CountT i = 0; i < (CountT)num_prims; i++) { - CountT prim_idx = prim_offset + i; - - AABB prim_aabb = obj_mgr_->primitiveAABBs[prim_idx]; - if (!prim_aabb.rayIntersects(obj_ray_o, inv_obj_ray_d, 0.f, t_max)) { - continue; - } - - bool hit_prim; - - const CollisionPrimitive *prim = - &obj_mgr_->collisionPrimitives[prim_idx]; - switch (prim->type) { - case CollisionPrimitive::Type::Hull: { - hit_prim = traceRayIntoConvexPolyhedron(prim->hull.halfEdgeMesh, - obj_ray_o, obj_ray_d, t_min, t_max, hit_t, &obj_hit_normal); - } break; - case CollisionPrimitive::Type::Plane: { - hit_prim = traceRayIntoPlane( - obj_ray_o, obj_ray_d, t_min, t_max, hit_t, &obj_hit_normal); - } break; - case CollisionPrimitive::Type::Sphere: { - assert(false); - } break; - default: MADRONA_UNREACHABLE(); - } - - if (hit_prim) { - hit_leaf = true; - t_max = *hit_t; - } - } - - if (hit_leaf) { - *hit_normal = leaf_txfm.rot.rotateVec(obj_hit_normal); - - return true; - } else { - return false; - } -} - -inline void updateLeafPositionsEntry( - Context &ctx, - const LeafID &leaf_id, - const Position &pos, - const Rotation &rot, - const Scale &scale, - const ObjectID &obj_id, - const Velocity &vel) -{ - BVH &bvh = ctx.singleton(); - ObjectManager &obj_mgr = *ctx.singleton().mgr; - AABB obj_aabb = obj_mgr.rigidBodyAABBs[obj_id.idx]; - - bvh.updateLeafPosition(leaf_id, pos, rot, scale, vel.linear, obj_aabb); -} - -// FIXME currently unused -inline void expandLeavesEntry( - Context &ctx, - const LeafID &leaf_id, - const Velocity &vel) -{ - BVH &bvh = ctx.singleton(); - AABB expanded = bvh.expandLeaf(leaf_id, vel.linear); - bvh.refitLeaf(leaf_id, expanded); -} - -inline void updateBVHEntry(Context &, BVH &bvh) -{ - bvh.updateTree(); -} - -inline void refitEntry(Context &ctx, LeafID leaf_id) -{ - BVH &bvh = ctx.singleton(); - bvh.refitLeaf(leaf_id, bvh.getLeafAABB(leaf_id)); -} - -inline void findIntersectingEntry( - Context &ctx, - const Entity &e, - LeafID leaf_id) -{ - BVH &bvh = ctx.singleton(); - ObjectManager &obj_mgr = *ctx.singleton().mgr; - - // FIXME: should have a flag for passing this - // directly into the system - Loc a_loc = ctx.loc(e); - bool a_is_static = - ctx.getDirect(RGDCols::ResponseType, a_loc) == - ResponseType::Static; - - ObjectID a_obj = ctx.getDirect(RGDCols::ObjectID, a_loc); - - CountT a_num_prims = obj_mgr.rigidBodyPrimitiveCounts[a_obj.idx]; - - bvh.findLeafIntersecting(leaf_id, [&](Entity intersecting_entity) { - if (e.id < intersecting_entity.id) { - Loc b_loc = ctx.loc(intersecting_entity); - - // FIXME: Change this so static objects are kept in a separate BVH - // and this check can be removed. - if (a_is_static && - ctx.getDirect(RGDCols::ResponseType, b_loc) == - ResponseType::Static) { - return; - } - - // We don't expand the primitive AABBs by movement (only object - // AABBs) so we just unconditionally emit narrowphase checks - // between each pair of primitives in the entity. Narrowphase - // will check transformed AABBs. - - ObjectID b_obj = ctx.getDirect(RGDCols::ObjectID, b_loc); - CountT b_num_prims = - obj_mgr.rigidBodyPrimitiveCounts[b_obj.idx]; - - // FIXME: would be nice to be able to make N temporaries all at - // once - - CountT total_narrowphase_checks = a_num_prims * b_num_prims; - - for (CountT prim_check_idx = 0; - prim_check_idx < total_narrowphase_checks; - prim_check_idx++) { - CountT a_prim_idx = prim_check_idx / b_num_prims; - CountT b_prim_idx = prim_check_idx % b_num_prims; - - Loc candidate_loc = ctx.makeTemporary(); - CandidateCollision &candidate = - ctx.getDirect( - RGDCols::CandidateCollision, candidate_loc); - - candidate.a = a_loc; - candidate.b = b_loc; - candidate.aPrim = a_prim_idx; - candidate.bPrim = b_prim_idx; - } - } - }); -} - -TaskGraphNodeID setupBVHTasks( - TaskGraphBuilder &builder, - Span deps) -{ - auto update_leaves = - builder.addToGraph>(deps); - - auto bvh_update = builder.addToGraph>({update_leaves}); - - // FIXME Unfortunately need to call refit here, because update - // won't necessarily do anything - auto refit = builder.addToGraph>({bvh_update}); - - return refit; -} - -TaskGraphNodeID setupPreIntegrationTasks( - TaskGraphBuilder &builder, - Span deps) -{ - auto find_intersects = builder.addToGraph>(deps); - - return find_intersects; -} - -TaskGraphNodeID setupPostIntegrationTasks( - TaskGraphBuilder &builder, - Span deps) -{ -#if 0 - auto expand_leaves = builder.addToGraph>({deps}); -#endif - - // FIXME: can we avoid doing a full tree refit here? - auto update_leaves = - builder.addToGraph>(deps); - - auto refit = builder.addToGraph>({update_leaves}); - - return refit; -} - -} diff --git a/src/physics/narrowphase.cpp b/src/physics/narrowphase.cpp deleted file mode 100644 index 4877673c..00000000 --- a/src/physics/narrowphase.cpp +++ /dev/null @@ -1,1964 +0,0 @@ -#include -#include -#include - -#include "physics_impl.hpp" - -#ifdef MADRONA_GPU_MODE -#include -#include -//#define COUNT_GPU_CLOCKS -#endif - -#ifdef COUNT_GPU_CLOCKS -#define MADRONA_COUNT_CLOCKS -extern "C" { -AtomicU64 narrowphaseAllClocks = 0; -AtomicU64 narrowphaseFetchWorldClocks = 0; -AtomicU64 narrowphaseSetupClocks = 0; -AtomicU64 narrowphasePrepClocks = 0; -AtomicU64 narrowphaseSwitchClocks = 0; -AtomicU64 narrowphaseSATFaceClocks = 0; -AtomicU64 narrowphaseSATEdgeClocks = 0; -AtomicU64 narrowphaseSATPlaneClocks = 0; -AtomicU64 narrowphaseSATContactClocks = 0; -AtomicU64 narrowphaseSATPlaneContactClocks = 0; -AtomicU64 narrowphaseSaveContactsClocks = 0; -AtomicU64 narrowphaseTxfmHullCtrs = 0; -AtomicU64 narrowphaseSATFinishClocks = 0; -} -#endif - -#ifdef MADRONA_COUNT_CLOCKS - -class ClockHelper { -public: - inline ClockHelper(AtomicU64 &counter) - : counter_(&counter) - { - cuda::atomic_thread_fence(cuda::memory_order_seq_cst, - cuda::thread_scope_thread); - start_ = timestamp(); - } - - inline void end() - { - cuda::atomic_thread_fence(cuda::memory_order_seq_cst, - cuda::thread_scope_thread); - auto end = timestamp(); - counter_->fetch_add_relaxed(end - start_); - counter_ = nullptr; - } - - inline ~ClockHelper() - { - if (counter_ != nullptr) { - end(); - } - } - -private: - inline uint64_t timestamp() const - { - uint64_t v; - asm volatile("mov.u64 %0, %%globaltimer;" - : "=l"(v)); - return v; - } - - AtomicU64 *counter_; - uint64_t start_; -}; - -#define PROF_START(name, counter) \ - ClockHelper name(counter) - -#define PROF_END(name) name.end() - -#endif - -#ifndef PROF_START -#define PROF_START(name, counter) -#define PROF_END(name) -#endif - -// Unconditionally disable GPU narrowphase version -#undef MADRONA_GPU_MODE -#undef MADRONA_GPU_COND -#define MADRONA_GPU_COND(...) - -namespace madrona::phys::narrowphase { - -using namespace base; -using namespace math; -using namespace geo; - -enum class NarrowphaseTest : uint32_t { - SphereSphere = 1, - HullHull = 2, - SphereHull = 3, - PlanePlane = 4, - SpherePlane = 5, - HullPlane = 6, -}; - -struct FaceQuery { - float separation; - CountT faceIdx; - Plane plane; -}; - -struct EdgeQuery { - float separation; - math::Vector3 normal; - int32_t edgeIdxA; - int32_t edgeIdxB; -}; - -struct HullState { - HalfEdgeMesh mesh; - Vector3 center; -}; - -struct Manifold { - math::Vector3 contactPoints[4]; - float penetrationDepths[4]; - int32_t numContactPoints; - math::Vector3 normal; -}; - -enum class ContactType { - None, - Sphere, - SATPlane, - SATFace, - SATEdge, -}; - -struct SphereContact { - Vector3 normal; - Vector3 pt; - float depth; -}; - -struct SATContact { - Vector3 normal; - float planeDOrSeparation; - uint32_t refFaceIdxOrEdgeIdxA; - uint32_t incidentFaceIdxOrEdgeIdxB; -}; - -static HullState makeHullState( - MADRONA_GPU_COND(const int32_t mwgpu_lane_id,) - const HalfEdgeMesh &mesh, - Vector3 translation, - Quat rotation, - Diag3x3 scale, - math::Vector3 *dst_vertices, - Plane *dst_planes) -{ - Mat3x3 unscaled_rot = Mat3x3::fromQuat(rotation); - Mat3x3 vertex_txfm = unscaled_rot * scale; - Mat3x3 normal_txfm = unscaled_rot * scale.inv(); - -#ifdef MADRONA_GPU_MODE - const CountT start_offset = mwgpu_lane_id; - constexpr CountT elems_per_iter = mwGPU::numWarpThreads; -#else - constexpr CountT start_offset = 0; - constexpr CountT elems_per_iter = 1; -#endif - - // FIXME: get rid of this center computation - store the offset from - // COM in each CollisionPrimitive and translate it - Vector3 center = Vector3::zero(); - - const CountT num_vertices = mesh.numVertices; - for (CountT i = start_offset; i < num_vertices; i += elems_per_iter) { - Vector3 world_pos = vertex_txfm * mesh.vertices[i] + translation; - dst_vertices[i] = world_pos; - center += world_pos; - } - - center /= num_vertices; - - // FIXME: could significantly optimize this with a uniform scale - // version - const CountT num_faces = mesh.numFaces; - for (CountT i = start_offset; i < num_faces; i += elems_per_iter) { - Plane obj_plane = mesh.facePlanes[i]; - Vector3 plane_origin = - vertex_txfm * (obj_plane.normal * obj_plane.d) + translation; - - Vector3 txfmed_normal = (normal_txfm * obj_plane.normal).normalize(); - float new_d = dot(txfmed_normal, plane_origin); - - dst_planes[i] = { - txfmed_normal, - new_d, - }; - - // Center should be behind each face plane (otherwise face normals - // are probably facing the wrong way) - this is too tight a loop - // to have this assert running all the time though - //assert(center.dot(txfmed_normal) - new_d < 0.0f); - } - - HalfEdgeMesh new_mesh { - .halfEdges = mesh.halfEdges, - .faceBaseHalfEdges = mesh.faceBaseHalfEdges, - .facePlanes = dst_planes, - .vertices = dst_vertices, - .numHalfEdges = mesh.numHalfEdges, - .numFaces = uint32_t(num_faces), - .numVertices = uint32_t(num_vertices), - }; - - return HullState { - new_mesh, - center, - }; - -} - -// Returns the signed distance -static inline float getDistanceFromPlane( - const Plane &plane, const Vector3 &a) -{ - float adotn = dot(a, plane.normal); - return adotn - plane.d; -} - -// Get intersection on plane of the line passing through 2 points -inline math::Vector3 planeIntersection(const Plane &plane, const math::Vector3 &p1, const math::Vector3 &p2) { - float distance = getDistanceFromPlane(plane, p1); - - return p1 + (p2 - p1) * (-distance / plane.normal.dot(p2 - p1)); -} - -#ifdef MADRONA_GPU_MODE - -MADRONA_ALWAYS_INLINE static inline std::pair -warpFloatMaxAndIdx(float val, int32_t idx) -{ -#pragma unroll - for (int32_t w = 16; w >= 1; w /= 2) { - float other_val = - __shfl_xor_sync(mwGPU::allActive, val, w); - int32_t other_idx = - __shfl_xor_sync(mwGPU::allActive, idx, w); - - if (other_val > val) { - val = other_val; - idx = other_idx; - } - } - - return { val, idx }; -} - -MADRONA_ALWAYS_INLINE static inline std::pair -warpFloatMinAndIdx(float val, int32_t idx) -{ -#pragma unroll - for (int32_t w = 16; w >= 1; w /= 2) { - float other_val = - __shfl_xor_sync(mwGPU::allActive, val, w); - int32_t other_idx = - __shfl_xor_sync(mwGPU::allActive, idx, w); - - if (other_val < val) { - val = other_val; - idx = other_idx; - } - } - - return { val, idx }; -} - -MADRONA_ALWAYS_INLINE static inline float warpFloatMin(float val) -{ -#pragma unroll - for (int32_t w = 16; w >= 1; w /= 2) { - float other_val = - __shfl_xor_sync(mwGPU::allActive, val, w); - - if (other_val < val) { - val = other_val; - } - } - - return val; -} - -#endif - -static float getHullDistanceFromPlane( - MADRONA_GPU_COND(const int32_t mwgpu_lane_id,) - const Plane &plane, const HullState &h) -{ -#ifdef MADRONA_GPU_MODE - constexpr CountT elems_per_iter = 32; -#else - constexpr CountT elems_per_iter = 1; -#endif - - float min_dot_n = FLT_MAX; - - auto computeVertexDotN = [&h, &plane](CountT vert_idx) { - Vector3 vertex = h.mesh.vertices[vert_idx]; - return dot(vertex, plane.normal); - }; - - const CountT num_verts = (CountT)h.mesh.numVertices; - for (int32_t offset = 0; offset < num_verts; offset += elems_per_iter) { -#ifdef MADRONA_GPU_MODE - int32_t vert_idx = offset + mwgpu_lane_id; - float cur_dot; - if (vert_idx < num_verts) { - cur_dot = computeVertexDotN(vert_idx); - } else { - cur_dot = FLT_MAX; - } -#else - float cur_dot = computeVertexDotN(offset); -#endif - - if (cur_dot < min_dot_n) { - min_dot_n = cur_dot; - } - } - -#ifdef MADRONA_GPU_MODE - min_dot_n = warpFloatMin(min_dot_n); -#endif - - return min_dot_n - plane.d; -} - -static FaceQuery queryFaceDirections( - MADRONA_GPU_COND(int32_t mwgpu_lane_id,) - const HullState &a, const HullState &b) -{ - Plane max_face_plane; - CountT max_dist_face = -1; - float max_dist = -FLT_MAX; - - const CountT num_a_faces = (CountT)a.mesh.numFaces; - for (CountT face_idx = 0; face_idx < num_a_faces; face_idx++) { - Plane plane = a.mesh.facePlanes[face_idx]; - float face_dist = getHullDistanceFromPlane( - MADRONA_GPU_COND(mwgpu_lane_id,) plane, b); - - if (face_dist > max_dist) { - max_dist = face_dist; - max_dist_face = face_idx; - max_face_plane = plane; - - if (max_dist > 0) { - break; - } - } - } - - return { max_dist, max_dist_face, max_face_plane }; -} - -static bool isMinkowskiFace( - const math::Vector3 &a, const math::Vector3 &b, - const math::Vector3 &c, const math::Vector3 &d) -{ - math::Vector3 bxa = b.cross(a); - math::Vector3 dxc = d.cross(c); - - float cba = c.dot(bxa); - float dba = d.dot(bxa); - float adc = a.dot(dxc); - float bdc = b.dot(dxc); - - return cba * dba < 0.0f && adc * bdc < 0.0f && cba * bdc > 0.0f; -} - -static inline std::pair getEdgeNormals( - const HalfEdgeMesh &mesh, HalfEdge cur_hedge, HalfEdge twin_hedge) -{ - Vector3 normal1 = mesh.facePlanes[cur_hedge.face].normal; - Vector3 normal2 = mesh.facePlanes[twin_hedge.face].normal; - - return { normal1, normal2 }; -} - -static inline Segment getEdgeSegment(const Vector3 *vertices, - const HalfEdge *hedges, - HalfEdge start) -{ - Vector3 a = vertices[start.rootVertex]; - - // FIXME: probably should put both vertex indices inline in the half edge - Vector3 b = vertices[hedges[start.next].rootVertex]; - - return { a, b }; -} - -static inline bool buildsMinkowskiFace( - const HalfEdgeMesh &a_mesh, const HalfEdgeMesh &b_mesh, - HalfEdge cur_hedge_a, HalfEdge twin_hedge_a, - HalfEdge cur_hedge_b, HalfEdge twin_hedge_b) -{ - auto [aNormal1, aNormal2] = - getEdgeNormals(a_mesh, cur_hedge_a, twin_hedge_a); - auto [bNormal1, bNormal2] = - getEdgeNormals(b_mesh, cur_hedge_b, twin_hedge_b); - - return isMinkowskiFace(aNormal1, aNormal2, -bNormal1, -bNormal2); -} - -struct EdgeTestResult { - Vector3 normal; - float separation; -}; - -static inline EdgeTestResult edgeDistance( - const HullState &a, const HullState &b, - HalfEdge hedge_a, HalfEdge hedge_b) -{ - Segment segment_a = - getEdgeSegment(a.mesh.vertices, a.mesh.halfEdges, hedge_a); - Segment segment_b = - getEdgeSegment(b.mesh.vertices, b.mesh.halfEdges, hedge_b); - - Vector3 dir_a = segment_a.p2 - segment_a.p1; - Vector3 dir_b = segment_b.p2 - segment_b.p1; - - Vector3 unnormalized_cross = dir_a.cross(dir_b); - float normal_len2 = unnormalized_cross.length2(); - - if (normal_len2 == 0) { - EdgeTestResult result; - result.separation = -FLT_MAX; - - return result; - } - - float inv_normal_len = -#ifdef MADRONA_GPU_MODE - rsqrtf(normal_len2); -#else - 1.f / sqrtf(normal_len2); -#endif - - math::Vector3 normal = unnormalized_cross * inv_normal_len; - - if (normal.dot(segment_a.p1 - a.center) < 0.0f) { - normal = -normal; - } - - float separation = normal.dot(segment_b.p1 - segment_a.p1); - - return { - normal, - separation, - }; -} - -static EdgeQuery queryEdgeDirections( - MADRONA_GPU_COND(int32_t mwgpu_lane_id,) - const HullState &a, const HullState &b) -{ - Vector3 normal {}; - int edgeAMaxDistance = 0; - int edgeBMaxDistance = 0; - float maxDistance = -FLT_MAX; - - auto testEdgeSeparation = [&a, &b](uint32_t hedge_idx_a, - uint32_t hedge_idx_b) { - HalfEdge cur_hedge_a = a.mesh.halfEdges[hedge_idx_a]; - HalfEdge twin_hedge_a = a.mesh.halfEdges[a.mesh.twinIDX(hedge_idx_a)]; - HalfEdge cur_hedge_b = b.mesh.halfEdges[hedge_idx_b]; - HalfEdge twin_hedge_b = b.mesh.halfEdges[b.mesh.twinIDX(hedge_idx_b)]; - - if (buildsMinkowskiFace(a.mesh, b.mesh, cur_hedge_a, twin_hedge_a, - cur_hedge_b, twin_hedge_b)) { - return edgeDistance(a, b, cur_hedge_a, cur_hedge_b); - } else { - EdgeTestResult result; - result.separation = -FLT_MAX; - return result; - } - }; - - const CountT a_num_edges = a.mesh.numEdges(); - const CountT b_num_edges = b.mesh.numEdges(); - -#ifdef MADRONA_GPU_MODE - const int32_t num_edge_tests = a_num_edges * b_num_edges; - for (int32_t edge_offset_linear = 0; edge_offset_linear < num_edge_tests; - edge_offset_linear += 32) { - int32_t edge_idx_linear = edge_offset_linear + mwgpu_lane_id; - - // FIXME: get rid of this level of indirection - int32_t edge_idx_a = edge_idx_linear / b_num_edges; - int32_t edge_idx_b = edge_idx_linear % b_num_edges; - - EdgeTestResult edge_cmp; - int32_t he_a_idx; - int32_t he_b_idx; - - if (edge_idx_linear >= num_edge_tests ) { - edge_cmp.separation = -FLT_MAX; - } else { - he_a_idx = a.mesh.edgeToHalfEdge(edge_idx_a); - he_b_idx = b.mesh.edgeToHalfEdge(edge_idx_b); - - edge_cmp = testEdgeSeparation(he_a_idx, he_b_idx); - } - - if (edge_cmp.separation > maxDistance) { - maxDistance = edge_cmp.separation; - normal = edge_cmp.normal; - edgeAMaxDistance = he_a_idx; - edgeBMaxDistance = he_b_idx; - } - - if (__ballot_sync(mwGPU::allActive, maxDistance > 0) != 0) { - break; - } - } - - int32_t max_lane_idx; - std::tie(maxDistance, max_lane_idx) = - warpFloatMaxAndIdx(maxDistance, mwgpu_lane_id); - - normal.x = __shfl_sync(mwGPU::allActive, normal.x, max_lane_idx); - normal.y = __shfl_sync(mwGPU::allActive, normal.y, max_lane_idx); - normal.z = __shfl_sync(mwGPU::allActive, normal.z, max_lane_idx); - - edgeAMaxDistance = __shfl_sync(mwGPU::allActive, - edgeAMaxDistance, max_lane_idx); - edgeBMaxDistance = __shfl_sync(mwGPU::allActive, - edgeBMaxDistance, max_lane_idx); - -#else - for (CountT edge_idx_a = 0; edge_idx_a < a_num_edges; edge_idx_a++) { - int32_t he_idx_a = a.mesh.edgeToHalfEdge(edge_idx_a); - for (CountT edge_idx_b = 0; edge_idx_b < b_num_edges; edge_idx_b++) { - int32_t he_idx_b = b.mesh.edgeToHalfEdge(edge_idx_b); - - EdgeTestResult edge_cmp = testEdgeSeparation(he_idx_a, he_idx_b); - - if (edge_cmp.separation > maxDistance) { - maxDistance = edge_cmp.separation; - normal = edge_cmp.normal; - edgeAMaxDistance = he_idx_a; - edgeBMaxDistance = he_idx_b; - - if (maxDistance > 0) { - // FIXME: this goto probably kills autovectorization - goto early_out; - } - } - } - } - - early_out: -#endif - - return { maxDistance, normal, edgeAMaxDistance, edgeBMaxDistance }; -} - -static CountT findIncidentFace(MADRONA_GPU_COND(int32_t mwgpu_lane_id,) - const HullState &h, Vector3 ref_normal) -{ -#ifdef MADRONA_GPU_MODE - constexpr CountT elems_per_iter = 32; -#else - constexpr CountT elems_per_iter = 1; -#endif - - auto computeFaceDotRef = [&h, ref_normal](CountT face_idx) { - Plane face_plane = h.mesh.facePlanes[face_idx]; - return dot(face_plane.normal, ref_normal); - }; - - float min_dot = FLT_MAX; - CountT minimizing_face = -1; - - const CountT num_faces = (CountT)h.mesh.numFaces; - for (CountT offset = 0; offset < num_faces; offset += elems_per_iter) { -#ifdef MADRONA_GPU_MODE - const CountT face_idx = offset + mwgpu_lane_id; - - float face_dot_ref; - if (face_idx < num_faces) { - face_dot_ref = computeFaceDotRef(face_idx); - } else{ - face_dot_ref = FLT_MAX; - } -#else - const CountT face_idx = offset; - float face_dot_ref = computeFaceDotRef(face_idx); -#endif - - if (face_dot_ref < min_dot) { - min_dot = face_dot_ref; - minimizing_face = face_idx; - } - } - -#ifdef MADRONA_GPU_MODE - std::tie(min_dot, minimizing_face) = - warpFloatMinAndIdx(min_dot, minimizing_face); -#endif - - assert(minimizing_face != -1); - return minimizing_face; -} - -static inline CountT clipPolygon(Vector3 *dst_vertices, - Plane clipping_plane, - const Vector3 *input_vertices, - CountT num_input_vertices) -{ - CountT num_new_vertices = 0; - - Vector3 v1 = input_vertices[num_input_vertices - 1]; - float d1 = getDistanceFromPlane(clipping_plane, v1); - - for (CountT i = 0; i < num_input_vertices; ++i) { - Vector3 v2 = input_vertices[i]; - float d2 = getDistanceFromPlane(clipping_plane, v2); - - if (d1 <= 0.0f && d2 <= 0.0f) { - // Both vertices are behind the plane, keep the second vertex - dst_vertices[num_new_vertices++] = v2; - } - else if (d1 <= 0.0f && d2 > 0.0f) { - // v1 is behind the plane, the other is in front (out) - Vector3 intersection = planeIntersection(clipping_plane, v1, v2); - dst_vertices[num_new_vertices++] = intersection; - } - else if (d2 <= 0.0f && d1 > 0.0f) { - math::Vector3 intersection = planeIntersection(clipping_plane, v1, v2); - dst_vertices[num_new_vertices++] = intersection; - dst_vertices[num_new_vertices++] = v2; - } - - // Now use v2 as the starting vertex - v1 = v2; - d1 = d2; - } - - return num_new_vertices; -} - -struct SATResult { - ContactType type; - SATContact contact; -}; - -static inline SATResult doSAT(MADRONA_GPU_COND(int32_t mwgpu_lane_id,) - const HullState &a, const HullState &b) -{ - PROF_START(sat_face_ctr, narrowphaseSATFaceClocks); - - FaceQuery faceQueryA = - queryFaceDirections(MADRONA_GPU_COND(mwgpu_lane_id,) a, b); - if (faceQueryA.separation > 0.0f) { - // There is a separating axis - no collision - SATResult result; - result.type = ContactType::None; - - return result; - } - - FaceQuery faceQueryB = - queryFaceDirections(MADRONA_GPU_COND(mwgpu_lane_id,) b, a); - if (faceQueryB.separation > 0.0f) { - // There is a separating axis - no collision - SATResult result; - result.type = ContactType::None; - - return result; - } - - PROF_END(sat_face_ctr); - PROF_START(sat_edge_ctr, narrowphaseSATEdgeClocks); - - EdgeQuery edgeQuery = - queryEdgeDirections(MADRONA_GPU_COND(mwgpu_lane_id,) a, b); - if (edgeQuery.separation > 0.0f) { - // There is a separating axis - no collision - SATResult result; - result.type = ContactType::None; - - return result; - } - - PROF_END(sat_edge_ctr); - - PROF_START(sat_finish_ctr, narrowphaseSATFinishClocks); - - bool bIsFaceContactA = faceQueryA.separation > edgeQuery.separation; - bool bIsFaceContactB = faceQueryB.separation > edgeQuery.separation; - - if (bIsFaceContactA || bIsFaceContactB) { - bool a_is_ref = faceQueryA.separation >= faceQueryB.separation; - - Plane ref_plane = a_is_ref ? faceQueryA.plane : faceQueryB.plane; - CountT ref_face_idx = - a_is_ref ? faceQueryA.faceIdx : faceQueryB.faceIdx; - const HullState &incident_hull = a_is_ref ? b : a; - - // Find incident face - CountT incident_face_idx = findIncidentFace( - MADRONA_GPU_COND(mwgpu_lane_id,) incident_hull, ref_plane.normal); - - SATResult result; - result.type = ContactType::SATFace, - result.contact.normal = ref_plane.normal; - result.contact.planeDOrSeparation = ref_plane.d; - uint32_t mask; - if (a_is_ref) { - mask = 0_u32; - } else { - mask = 1_u32 << 31_u32; - } - result.contact.refFaceIdxOrEdgeIdxA = uint32_t(ref_face_idx) | mask; - result.contact.incidentFaceIdxOrEdgeIdxB = uint32_t(incident_face_idx); - - return result; - } else { - SATResult result; - result.type = ContactType::SATEdge; - result.contact.normal = edgeQuery.normal; - result.contact.planeDOrSeparation = edgeQuery.separation; - result.contact.refFaceIdxOrEdgeIdxA = edgeQuery.edgeIdxA; - result.contact.incidentFaceIdxOrEdgeIdxB = edgeQuery.edgeIdxB; - return result; - } -} - -SATResult doSATPlane(MADRONA_GPU_COND(const int32_t mwgpu_lane_id,) - const Plane &plane, const HullState &h) -{ - PROF_START(sat_plane_ctr, narrowphaseSATPlaneClocks); - - float separation = getHullDistanceFromPlane( - MADRONA_GPU_COND(mwgpu_lane_id,) plane, h); - - if (separation > 0.0f) { - SATResult result; - result.type = ContactType::None; - - return result; - } - - PROF_START(sat_finish_ctr, narrowphaseSATFinishClocks); - - // Find incident face - CountT incident_face_idx = findIncidentFace( - MADRONA_GPU_COND(mwgpu_lane_id,) h, plane.normal); - - SATResult result; - result.type = ContactType::SATPlane; - result.contact.normal = plane.normal; - result.contact.planeDOrSeparation = plane.d; - result.contact.incidentFaceIdxOrEdgeIdxB = uint32_t(incident_face_idx); - - return result; -} - -static Manifold buildFaceContactManifold( - Vector3 contact_normal, - Vector3 *contacts, - float *penetration_depths, - CountT num_contacts, - Vector3 world_offset, - Quat to_world_frame) -{ - Manifold manifold; - if (num_contacts <= 4) { - manifold.numContactPoints = num_contacts; - for (CountT i = 0; i < num_contacts; i++) { - manifold.contactPoints[i] = contacts[i]; - manifold.penetrationDepths[i] = penetration_depths[i]; - } - } else { - // Going to select contact manifold comprised of points - // A B C and Q following Gregorious presentation. - - // Select point A as first point in contact list - manifold.numContactPoints = 4; - manifold.contactPoints[0] = contacts[0]; - manifold.penetrationDepths[0] = penetration_depths[0]; - - // Find point B furthest from point A - float max_dist_sq = 0.f; - for (CountT i = 1; i < num_contacts; i++) { - Vector3 cur_contact = contacts[i]; - float dist_sq = manifold.contactPoints[0].distance2(cur_contact); - if (dist_sq > max_dist_sq) { - max_dist_sq = dist_sq; - - manifold.contactPoints[1] = cur_contact; - manifold.penetrationDepths[1] = penetration_depths[i]; - } - } - - math::Vector3 ba = - manifold.contactPoints[1] - manifold.contactPoints[0]; - - // Find point C which maximizes area of triangle ABC - float max_tri_area = 0.0f; - bool max_tri_sign = 0.f; - for (CountT i = 1; i < num_contacts; i++) { - Vector3 cur_contact = contacts[i]; - math::Vector3 bc = cur_contact - manifold.contactPoints[1]; - float signed_area = contact_normal.dot(cross(ba, bc)); - float area = copysignf(signed_area, 1.f); - - if (area > max_tri_area) { - max_tri_area = area; - max_tri_sign = copysignf(1.f, signed_area); - - manifold.contactPoints[2] = cur_contact; - manifold.penetrationDepths[2] = penetration_depths[i]; - } - } - - // If we ultimately selected a triangle ABC with clockwise winding, - // flip around edge BA to make the triangle counterclockwise, so the - // next part only needs to search for negative area. - if (max_tri_sign == -1.f) { - ba = -ba; - std::swap(manifold.contactPoints[0], manifold.contactPoints[1]); - } - - // Select point Q that adds the most area to ABC - // Need to check ABQ (BA x AQ), BCQ (CB x QC), and CAQ (AC x QA) - - Vector3 cb = manifold.contactPoints[2] - manifold.contactPoints[1]; - Vector3 ac = manifold.contactPoints[0] - manifold.contactPoints[2]; - - float most_neg_area = 0.f; - for (CountT i = 1; i < num_contacts; i++) { - Vector3 cur_contact = contacts[i]; - - Vector3 aq = manifold.contactPoints[0] - cur_contact; - Vector3 qc = cur_contact - manifold.contactPoints[2]; - - float abq_area = contact_normal.dot(cross(ba, aq)); - float bcq_area = contact_normal.dot(cross(cb, qc)); - float caq_area = contact_normal.dot(cross(aq, ac)); - - float q_min_area = fminf(abq_area, fminf(bcq_area, caq_area)); - if (q_min_area < most_neg_area) { - most_neg_area = q_min_area; - - manifold.contactPoints[3] = cur_contact; - manifold.penetrationDepths[3] = penetration_depths[i]; - } - } - - if (max_dist_sq == 0.f || max_tri_area == 0.f || most_neg_area == 0.f) { - // FIXME: should not be possible - manifold.numContactPoints = 0; - manifold.normal = Vector3::zero(); - return manifold; - } - } - - for (CountT i = 0; i < (CountT)manifold.numContactPoints; i++) { - manifold.contactPoints[i] = - to_world_frame.rotateVec(manifold.contactPoints[i]) + world_offset; - } - - manifold.normal = to_world_frame.rotateVec(contact_normal); - - return manifold; -} - -MADRONA_ALWAYS_INLINE static inline Manifold createFaceContact( - Plane ref_plane, - int32_t ref_face_idx, - int32_t incident_face_idx, - const Vector3 *ref_vertices, - const Vector3 *other_vertices, - const HalfEdge *ref_hedges, - const HalfEdge *other_hedges, - const uint32_t *ref_face_hedges, - const uint32_t *other_face_hedges, - void *tmp_buf1, void *tmp_buf2, -#ifdef MADRONA_GPU_MODE - Mat3x4 ref_txfm, Mat3x4 other_txfm, -#endif - Vector3 world_offset, Quat to_world_frame) -{ - // Collect incident vertices: FIXME should have face indices - Vector3 *incident_vertices_tmp = (Vector3 *)tmp_buf1; - CountT num_incident_vertices = 0; - { - CountT hedge_idx = other_face_hedges[incident_face_idx]; - CountT start_hedge_idx = hedge_idx; - - do { - const auto &cur_hedge = other_hedges[hedge_idx]; - hedge_idx = cur_hedge.next; - - Vector3 cur_point = other_vertices[cur_hedge.rootVertex]; -#ifdef MADRONA_GPU_MODE - cur_point = other_txfm.txfmPoint(cur_point); -#endif - - incident_vertices_tmp[num_incident_vertices++] = cur_point; - } while (hedge_idx != start_hedge_idx); - } - - Vector3 *clipping_input = incident_vertices_tmp; - CountT num_clipped_vertices = num_incident_vertices; - - Vector3 *clipping_dst = (Vector3 *)tmp_buf2; - - // max output vertices is num_incident_vertices + num planes - // but we don't know num planes ahead of time without iterating - // through the reference face twice! Alternative would be to cache the - // side planes, or store max face size in each mesh. The worst case - // buffer sizes here is just the sum of the max face sizes - 1 - - // FIXME, this code assumes that clipping_input & clipping_dst have space - // to write incident_vertices + num_planes new vertices - // Loop over side planes - { - CountT hedge_idx = ref_face_hedges[ref_face_idx]; - CountT start_hedge_idx = hedge_idx; - - auto *cur_hedge = &ref_hedges[hedge_idx]; - Vector3 cur_point = ref_vertices[cur_hedge->rootVertex]; -#ifdef MADRONA_GPU_MODE - cur_point = ref_txfm.txfmPoint(cur_point); -#endif - do { - hedge_idx = cur_hedge->next; - cur_hedge = &ref_hedges[hedge_idx]; - Vector3 next_point = ref_vertices[cur_hedge->rootVertex]; -#ifdef MADRONA_GPU_MODE - next_point = ref_txfm.txfmPoint(next_point); -#endif - - Vector3 edge = next_point - cur_point; - Vector3 plane_normal = cross(edge, ref_plane.normal); - - float d = dot(plane_normal, cur_point); - cur_point = next_point; - - Plane side_plane { - plane_normal, - d, - }; - - num_clipped_vertices = clipPolygon(clipping_dst, side_plane, - clipping_input, num_clipped_vertices); - - std::swap(clipping_dst, clipping_input); - } while (hedge_idx != start_hedge_idx); - } - - // assert(num_clipped_vertices > 0); - - // clipping_input has the result due to the final swap - - // Filter clipping_input to ones below ref_plane and save penetration depth - float *penetration_depths = (float *)clipping_dst; - - CountT num_below_plane = 0; - for (CountT i = 0; i < num_clipped_vertices; ++i) { - Vector3 vertex = clipping_input[i]; - if (float d = getDistanceFromPlane(ref_plane, vertex); d <= 0.0f) { - // Project the point onto the reference plane - // (d guaranteed to be negative) - clipping_input[num_below_plane] = vertex - d * ref_plane.normal; - penetration_depths[num_below_plane] = -d; - - num_below_plane += 1; - } - } - - return buildFaceContactManifold(ref_plane.normal, clipping_input, - penetration_depths, num_below_plane, - world_offset, to_world_frame); -} - -static Manifold createFacePlaneContact(Plane plane, - int32_t incident_face_idx, - const Vector3 *vertices, - const HalfEdge *hedges, - const uint32_t *face_hedge_roots, - Vector3 *contacts_tmp, - float *penetration_depths_tmp, -#ifdef MADRONA_GPU_MODE - Mat3x4 hull_txfm, -#endif - Vector3 world_offset, - Quat to_world_frame) -{ - // Collect incident vertices: FIXME should have face indices - CountT num_incident_vertices = 0; - { - CountT hedge_idx = face_hedge_roots[incident_face_idx]; - CountT start_hedge_idx = hedge_idx; - - do { - const auto &cur_hedge = hedges[hedge_idx]; - hedge_idx = cur_hedge.next; - Vector3 vertex = vertices[cur_hedge.rootVertex]; - -#ifdef MADRONA_GPU_MODE - vertex = hull_txfm.txfmPoint(vertex); -#endif - - if (float d = getDistanceFromPlane(plane, vertex); d <= 0.0f) { - // Project the point onto the reference plane - // (d guaranteed to be negative) - contacts_tmp[num_incident_vertices] = - vertex - d * plane.normal; - penetration_depths_tmp[num_incident_vertices] = -d; - - num_incident_vertices += 1; - } - } while (hedge_idx != start_hedge_idx); - } - - return buildFaceContactManifold(plane.normal, contacts_tmp, - penetration_depths_tmp, num_incident_vertices, - world_offset, to_world_frame); -} - - -static Segment shortestSegmentBetween(const Segment &seg1, const Segment &seg2) -{ - math::Vector3 v1 = seg1.p2 - seg1.p1; - math::Vector3 v2 = seg2.p2 - seg2.p1; - - math::Vector3 v21 = seg2.p1 - seg1.p1; - - float dotv22 = v2.dot(v2); - float dotv11 = v1.dot(v1); - float dotv21 = v2.dot(v1); - float dotv211 = v21.dot(v1); - float dotv212 = v21.dot(v2); - - float denom = dotv21 * dotv21 - dotv22 * dotv11; - - float s, t; - - // FIXME: validate this epsilon - if (fabsf(denom) < 0.00001f) { - s = 0.0f; - t = (dotv11 * s - dotv211) / dotv21; - } - else { - s = (dotv212 * dotv21 - dotv22 * dotv211) / denom; - t = (-dotv211 * dotv21 + dotv11 * dotv212) / denom; - } - - s = fmaxf(fminf(s, 1.0f), 0.0f); - t = fmaxf(fminf(t, 1.0f), 0.0f); - - return { seg1.p1 + s * v1, seg2.p1 + t * v2 }; -} - -static Manifold createEdgeContact(Segment segA, - Segment segB, - Vector3 normal, - float separation, - Vector3 world_offset, - Quat to_world_frame) -{ -#if 0 - Segment s = shortestSegmentBetween(segA, segB); - Vector3 contact = 0.5f * (s.p1 + s.p2); - float depth = 0.5f * (s.p2 - s.p1).length(); -#endif - - // Deviation from Gregorius GDC 2015: - // Currently the solver expects the contact point to be ON object A. - // For Face-Face this means the point is on object A's face, which is - // the same as the presentation. In the edge-edge case, the presentation - // has the contact point between the edges. - // Our solver currently reconstructs the contact points on point B - // using the normal depth, so the presentation's method will - // reconstruct the wrong contact points. - // FIXME: revisit this after modifying solver to handle multi-point - // manifolds better - - // FIXME: don't need this full function call here. - Segment s = shortestSegmentBetween(segA, segB); - Vector3 contact = s.p1; - - Manifold manifold; - manifold.contactPoints[0] = - to_world_frame.rotateVec(contact) + world_offset, - manifold.penetrationDepths[0] = -separation; - manifold.numContactPoints = 1; - manifold.normal = to_world_frame.rotateVec(normal); - - return manifold; -} - -static Manifold createEdgeContact(Vector3 normal, - float separation, - int32_t hedge_idx_a, - int32_t hedge_idx_b, - const Vector3 *a_vertices, - const Vector3 *b_vertices, - const HalfEdge *a_hedges, - const HalfEdge *b_hedges, -#ifdef MADRONA_GPU_MODE - Vector3 a_pos, Quat a_rot, Diag3x3 a_scale, - Vector3 b_pos, Quat b_rot, Diag3x3 b_scale, -#endif - Vector3 world_offset, - Quat to_world_frame) -{ - Segment segA = getEdgeSegment(a_vertices, a_hedges, - a_hedges[hedge_idx_a]); - Segment segB = getEdgeSegment(b_vertices, b_hedges, - b_hedges[hedge_idx_b]); - -#ifdef MADRONA_GPU_MODE - segA.p1 = a_rot.rotateVec(a_scale * segA.p1) + a_pos; - segA.p2 = a_rot.rotateVec(a_scale * segA.p2) + a_pos; - segB.p1 = b_rot.rotateVec(b_scale * segB.p1) + b_pos; - segB.p2 = b_rot.rotateVec(b_scale * segB.p2) + b_pos; -#endif - - return createEdgeContact(segA, segB, - normal, separation, - world_offset, to_world_frame); -} - -static inline void addManifoldContacts( - Context &ctx, - Manifold manifold, - Loc ref_loc, Loc other_loc) -{ - PROF_START(save_contacts_ctr, narrowphaseSaveContactsClocks); - - const auto &physics_sys = ctx.singleton(); - - Loc c = ctx.makeTemporary(physics_sys.contactArchetypeID); - ctx.getDirect(RGDCols::ContactConstraint, c) = { - ref_loc, - other_loc, - { - Vector4::fromVec3W(manifold.contactPoints[0], - manifold.penetrationDepths[0]), - Vector4::fromVec3W(manifold.contactPoints[1], - manifold.penetrationDepths[1]), - Vector4::fromVec3W(manifold.contactPoints[2], - manifold.penetrationDepths[2]), - Vector4::fromVec3W(manifold.contactPoints[3], - manifold.penetrationDepths[3]), - }, - manifold.numContactPoints, - manifold.normal, - }; -} - -static inline void addSinglePointContact( - Context &ctx, - Vector3 point, - Vector3 normal, - float depth, - Loc ref_loc, - Loc other_loc) -{ - const auto &physics_sys = ctx.singleton(); - - Loc c = ctx.makeTemporary(physics_sys.contactArchetypeID); - - ctx.getDirect(RGDCols::ContactConstraint, c) = { - ref_loc, - other_loc, - { - Vector4::fromVec3W(point, depth), - Vector4::zero(), - Vector4::zero(), - Vector4::zero(), - }, - 1, - normal, - }; -} - -#ifdef MADRONA_GPU_MODE -namespace gpuImpl { -// FIXME: do something actually intelligent here -inline constexpr int32_t maxNumPlanes = 40; -inline constexpr int32_t numPlaneFloats = maxNumPlanes * 4; -} -#endif - -struct NarrowphaseResult { - ContactType type; - SphereContact sphere; - SATContact sat; - const Vector3 *aVertices; - const Vector3 *bVertices; - const HalfEdge *aHalfEdges; - const HalfEdge *bHalfEdges; - const uint32_t *aFaceHedgeRoots; - const uint32_t *bFaceHedgeRoots; -}; - -MADRONA_ALWAYS_INLINE static inline NarrowphaseResult narrowphaseDispatch( - MADRONA_GPU_COND(const int32_t mwgpu_lane_id,) - NarrowphaseTest test_type, - Vector3 a_pos, Vector3 b_pos, - Quat a_rot, Quat b_rot, - Diag3x3 a_scale, Diag3x3 b_scale, - const CollisionPrimitive *a_prim, const CollisionPrimitive *b_prim, - CountT max_num_tmp_vertices, - CountT max_num_tmp_faces, - Vector3 *txfm_vertex_buffer, - Plane *txfm_face_buffer) -{ - PROF_START(switch_body_ctr, narrowphaseSwitchClocks); - - switch (test_type) { - case NarrowphaseTest::SphereSphere: { - float a_radius, b_radius; - { - assert(a_scale.d0 == a_scale.d1 && a_scale.d0 == a_scale.d2); - assert(b_scale.d0 == b_scale.d1 && b_scale.d0 == b_scale.d2); - - a_radius = a_scale.d0 * a_prim->sphere.radius; - b_radius = b_scale.d0 * b_prim->sphere.radius; - } - - Vector3 to_b = b_pos - a_pos; - float dist = to_b.length(); - - if (dist > a_radius + b_radius) { - NarrowphaseResult result; - result.type = ContactType::None; - return result; - } - - Vector3 normal; - float penetration; - - if (dist > 0.f) { - normal = to_b / dist; - } else { - normal = math::up; - } - - penetration = a_radius + b_radius - dist; - - SphereContact contact { - .normal = normal, - .pt = a_pos + a_radius * normal, - .depth = penetration, - }; - - NarrowphaseResult result; - result.type = ContactType::Sphere; - result.sphere = contact; - result.aVertices = nullptr; - result.bVertices = nullptr; - result.aVertices = nullptr; - result.bVertices = nullptr; - result.aHalfEdges = nullptr; - result.bHalfEdges = nullptr; - result.aFaceHedgeRoots = nullptr; - result.bFaceHedgeRoots = nullptr; - return result; - } break; - case NarrowphaseTest::HullHull: { - // Get half edge mesh for hull A and hull B - const auto &a_he_mesh = a_prim->hull.halfEdgeMesh; - const auto &b_he_mesh = b_prim->hull.halfEdgeMesh; - - assert(a_he_mesh.numFaces + b_he_mesh.numFaces < - max_num_tmp_faces); - - assert(a_he_mesh.numVertices + b_he_mesh.numVertices < - max_num_tmp_vertices); - - PROF_START(txfm_hull_ctr, narrowphaseTxfmHullCtrs); - - HullState a_hull_state = makeHullState(MADRONA_GPU_COND(mwgpu_lane_id,) - a_he_mesh, a_pos, a_rot, a_scale, txfm_vertex_buffer, - txfm_face_buffer); - - txfm_vertex_buffer += a_hull_state.mesh.numVertices; - txfm_face_buffer += a_hull_state.mesh.numFaces; - - HullState b_hull_state = makeHullState(MADRONA_GPU_COND(mwgpu_lane_id,) - b_he_mesh, b_pos, b_rot, b_scale, txfm_vertex_buffer, - txfm_face_buffer); - - MADRONA_GPU_COND(__syncwarp(mwGPU::allActive)); - - PROF_END(txfm_hull_ctr); - - const SATResult sat = doSAT(MADRONA_GPU_COND(mwgpu_lane_id,) - a_hull_state, b_hull_state); - - NarrowphaseResult result; - result.type = sat.type; - result.sat = sat.contact; -#ifdef MADRONA_GPU_MODE - result.aVertices = a_he_mesh.vertices; - result.bVertices = b_he_mesh.vertices; -#else - result.aVertices = a_hull_state.mesh.vertices; - result.bVertices = b_hull_state.mesh.vertices; -#endif - result.aHalfEdges = a_hull_state.mesh.halfEdges; - result.bHalfEdges = b_hull_state.mesh.halfEdges; - result.aFaceHedgeRoots = a_hull_state.mesh.faceBaseHalfEdges; - result.bFaceHedgeRoots = b_hull_state.mesh.faceBaseHalfEdges; - - return result; - } break; - case NarrowphaseTest::SphereHull: { - float sphere_radius; - { - auto sphere = a_prim->sphere; - assert(a_scale.d0 == a_scale.d1 && a_scale.d0 == a_scale.d2); - sphere_radius = a_scale.d0 * sphere.radius; - } - - const auto &b_he_mesh = b_prim->hull.halfEdgeMesh; - assert(b_he_mesh.numFaces < max_num_tmp_faces); - assert(b_he_mesh.numVertices < max_num_tmp_vertices); - - PROF_START(txfm_hull_ctr, narrowphaseTxfmHullCtrs); - - Vector3 hull_origin = b_pos - a_pos; - - HullState b_hull_state = makeHullState(MADRONA_GPU_COND(mwgpu_lane_id,) - b_he_mesh, hull_origin, b_rot, b_scale, - txfm_vertex_buffer, txfm_face_buffer); - - MADRONA_GPU_COND(__syncwarp(mwGPU::allActive)); - - PROF_END(txfm_hull_ctr); - - Vector3 to_hull_closest_pt; - float hull_dist2 = hullClosestPointToOriginGJK( - b_hull_state.mesh, 1e-10f, &to_hull_closest_pt); - - if (hull_dist2 > sphere_radius * sphere_radius) { - NarrowphaseResult result; - result.type = ContactType::None; - return result; - } - - SphereContact sphere_contact; - - if (hull_dist2 == 0.f) { - // Need to do SAT - float max_sep = -FLT_MAX; - Vector3 sep_normal; - const CountT num_faces = b_hull_state.mesh.numFaces; - for (CountT i = 0; i < num_faces; i++) { - Plane plane = b_hull_state.mesh.facePlanes[i]; - // hull has already been moved so sphere is at origin - float face_dist = -plane.d; - - if (face_dist > max_sep) { - max_sep = face_dist; - sep_normal = plane.normal; - } - } - - // Discrepancy between SAT and GJK - if (max_sep > 0.f) { - assert(max_sep < 1e-5f); - NarrowphaseResult result; - result.type = ContactType::None; - return result; - } - sphere_contact.normal = sep_normal; - sphere_contact.pt = a_pos + sep_normal * sphere_radius; - sphere_contact.depth = -max_sep; - } else { - float to_hull_len = sqrtf(hull_dist2); - - float depth = sphere_radius - to_hull_len; - Vector3 normal = to_hull_closest_pt / to_hull_len; - - sphere_contact.normal = -normal; - sphere_contact.pt = a_pos + normal * sphere_radius; - sphere_contact.depth = depth; - } - - NarrowphaseResult result; - result.type = ContactType::Sphere; - result.sphere = sphere_contact; - result.aVertices = nullptr; - result.bVertices = nullptr; - result.aVertices = nullptr; - result.bVertices = nullptr; - result.aHalfEdges = nullptr; - result.bHalfEdges = nullptr; - result.aFaceHedgeRoots = nullptr; - result.bFaceHedgeRoots = nullptr; - return result; - } break; - case NarrowphaseTest::PlanePlane: { - // Planes must be static, this should never be called - assert(false); - MADRONA_UNREACHABLE(); - } break; - case NarrowphaseTest::SpherePlane: { - float sphere_radius; - { - auto sphere = a_prim->sphere; - assert(a_scale.d0 == a_scale.d1 && a_scale.d0 == a_scale.d2); - sphere_radius = a_scale.d0 * sphere.radius; - } - - constexpr Vector3 base_normal = { 0, 0, 1 }; - Vector3 plane_normal = b_rot.rotateVec(base_normal); - - float d = plane_normal.dot(b_pos); - float t = plane_normal.dot(a_pos) - d; - - float penetration = sphere_radius - t; - if (penetration < 0) { - NarrowphaseResult result; - result.type = ContactType::None; - return result; - } - - Vector3 contact_point = a_pos - t * plane_normal; - - SphereContact sphere_contact { - .normal = plane_normal, - .pt = contact_point, - .depth = penetration, - }; - - NarrowphaseResult result; - result.type = ContactType::Sphere; - result.sphere = sphere_contact; - result.aVertices = nullptr; - result.bVertices = nullptr; - result.aVertices = nullptr; - result.bVertices = nullptr; - result.aHalfEdges = nullptr; - result.bHalfEdges = nullptr; - result.aFaceHedgeRoots = nullptr; - result.bFaceHedgeRoots = nullptr; - return result; - } break; - case NarrowphaseTest::HullPlane: { - // Get half edge mesh for entity a (the hull) - const auto &a_he_mesh = a_prim->hull.halfEdgeMesh; - - assert(a_he_mesh.numFaces < max_num_tmp_faces); - assert(a_he_mesh.numVertices < max_num_tmp_vertices); - - PROF_START(txfm_hull_ctr, narrowphaseTxfmHullCtrs); - - HullState a_hull_state = makeHullState(MADRONA_GPU_COND(mwgpu_lane_id,) - a_he_mesh, a_pos, a_rot, a_scale, - txfm_vertex_buffer, txfm_face_buffer); - - MADRONA_GPU_COND(__syncwarp(mwGPU::allActive)); - - PROF_END(txfm_hull_ctr); - - constexpr Vector3 base_normal = { 0, 0, 1 }; -#if 0 - Quat inv_a_rot = a_rot.inv(); - Vector3 plane_origin_a_local = inv_a_rot.rotateVec(b_pos - a_pos); - Quat to_a_local_rot = (inv_a_rot * b_rot).normalize(); - - Vector3 plane_normal_a_local = - (to_a_local_rot.rotateVec(base_normal)).normalize(); -#endif - - Vector3 plane_normal = b_rot.rotateVec(base_normal); - - Plane plane { - plane_normal, - dot(plane_normal, b_pos), - }; - - const SATResult sat = doSATPlane( - MADRONA_GPU_COND(mwgpu_lane_id,) plane, a_hull_state); - - NarrowphaseResult result; - result.type = sat.type; - result.sat = sat.contact; -#ifdef MADRONA_GPU_MODE - result.aVertices = a_he_mesh.vertices; - result.bVertices = nullptr; -#else - result.aVertices = a_hull_state.mesh.vertices; - result.bVertices = nullptr; -#endif - result.aHalfEdges = a_hull_state.mesh.halfEdges; - result.bHalfEdges = nullptr; - result.aFaceHedgeRoots = a_hull_state.mesh.faceBaseHalfEdges; - result.bFaceHedgeRoots = nullptr; - return result; - } break; - default: MADRONA_UNREACHABLE(); - } -} - -MADRONA_ALWAYS_INLINE static inline void generateContacts( - Context &ctx, - NarrowphaseResult narrowphase_result, - Loc a_loc, Loc b_loc, -#ifdef MADRONA_GPU_MODE - Vector3 a_pos, Quat a_rot, Diag3x3 a_scale, - Vector3 b_pos, Quat b_rot, Diag3x3 b_scale, -#endif - void *thread_tmp_storage_a, void *thread_tmp_storage_b) -{ - switch (narrowphase_result.type) { - case ContactType::None: { - return; - } break; - case ContactType::Sphere: { - SphereContact sphere_contact = narrowphase_result.sphere; - - addSinglePointContact(ctx, sphere_contact.pt, sphere_contact.normal, - sphere_contact.depth, b_loc, a_loc); - } break; - case ContactType::SATPlane: { - // Plane is always b, always reference - Loc ref_loc = b_loc; - Loc other_loc = a_loc; - -#ifdef MADRONA_GPU_MODE - Mat3x4 hull_txfm = Mat3x4::fromTRS(a_pos, a_rot, a_scale); -#endif - - Plane plane { - narrowphase_result.sat.normal, - narrowphase_result.sat.planeDOrSeparation, - }; - - // Create plane contact - Manifold manifold = createFacePlaneContact( - plane, - int32_t(narrowphase_result.sat.incidentFaceIdxOrEdgeIdxB), - narrowphase_result.aVertices, - narrowphase_result.aHalfEdges, - narrowphase_result.aFaceHedgeRoots, - (Vector3 *)thread_tmp_storage_a, - (float *)thread_tmp_storage_b, -#ifdef MADRONA_GPU_MODE - hull_txfm, -#endif - { 0, 0, 0, }, - { 1, 0, 0, 0 }); - - // Sadly there are cases where two objects are just barely - // touching and post contact clipping all the clipped contacts - // are just barely separated due to FP32. For now just don't - // make a Contact in this situation. - if (manifold.numContactPoints > 0) { - addManifoldContacts(ctx, manifold, ref_loc, other_loc); - } - } break; - case ContactType::SATFace: { - const Vector3 *ref_vertices; - const Vector3 *other_vertices; - const HalfEdge *ref_hedges; - const HalfEdge *other_hedges; - const uint32_t *ref_face_hedges; - const uint32_t *other_face_hedges; - -#ifdef MADRONA_GPU_MODE - Mat3x4 ref_txfm; - Mat3x4 other_txfm; -#endif - - uint32_t ref_face_idx_and_ref_mask = - narrowphase_result.sat.refFaceIdxOrEdgeIdxA; - uint32_t incident_face_idx = - narrowphase_result.sat.incidentFaceIdxOrEdgeIdxB; - - uint32_t ref_face_idx = ref_face_idx_and_ref_mask & 0x7FFF'FFFF; - bool a_is_ref = ref_face_idx == ref_face_idx_and_ref_mask; - - Loc ref_loc, other_loc; - if (a_is_ref) { - ref_loc = a_loc; - other_loc = b_loc; - ref_vertices = narrowphase_result.aVertices; - other_vertices = narrowphase_result.bVertices; - ref_hedges = narrowphase_result.aHalfEdges; - other_hedges = narrowphase_result.bHalfEdges; - ref_face_hedges = narrowphase_result.aFaceHedgeRoots; - other_face_hedges = narrowphase_result.bFaceHedgeRoots; -#ifdef MADRONA_GPU_MODE - ref_txfm = Mat3x4::fromTRS(a_pos, a_rot, a_scale); - other_txfm = Mat3x4::fromTRS(b_pos, b_rot, b_scale); -#endif - } else { - ref_loc = b_loc; - other_loc = a_loc; - ref_vertices = narrowphase_result.bVertices; - other_vertices = narrowphase_result.aVertices; - ref_hedges = narrowphase_result.bHalfEdges; - other_hedges = narrowphase_result.aHalfEdges; - ref_face_hedges = narrowphase_result.bFaceHedgeRoots; - other_face_hedges = narrowphase_result.aFaceHedgeRoots; -#ifdef MADRONA_GPU_MODE - ref_txfm = Mat3x4::fromTRS(b_pos, b_rot, b_scale); - other_txfm = Mat3x4::fromTRS(a_pos, a_rot, a_scale); -#endif - } - - Plane ref_plane { - narrowphase_result.sat.normal, - narrowphase_result.sat.planeDOrSeparation, - }; - - // Create face contact - Manifold manifold = createFaceContact( - ref_plane, - int32_t(ref_face_idx), - int32_t(incident_face_idx), - ref_vertices, - other_vertices, - ref_hedges, - other_hedges, - ref_face_hedges, - other_face_hedges, - thread_tmp_storage_a, thread_tmp_storage_b, -#ifdef MADRONA_GPU_MODE - ref_txfm, - other_txfm, -#endif - { 0, 0, 0, }, - { 1, 0, 0, 0 }); - - // Sadly there are cases where two objects are just barely - // touching and post contact clipping all the clipped contacts - // are just barely separated due to FP32. For now just don't - // make a Contact in this situation. - if (manifold.numContactPoints > 0) { - addManifoldContacts(ctx, manifold, ref_loc, other_loc); - } - } break; - case ContactType::SATEdge: { - // A is always reference - Loc ref_loc = a_loc; - Loc other_loc = b_loc; - - // Create edge contact - Manifold manifold = createEdgeContact( - narrowphase_result.sat.normal, - narrowphase_result.sat.planeDOrSeparation, - int32_t(narrowphase_result.sat.refFaceIdxOrEdgeIdxA), - int32_t(narrowphase_result.sat.incidentFaceIdxOrEdgeIdxB), - narrowphase_result.aVertices, - narrowphase_result.bVertices, - narrowphase_result.aHalfEdges, - narrowphase_result.bHalfEdges, -#ifdef MADRONA_GPU_MODE - a_pos, a_rot, a_scale, - b_pos, b_rot, b_scale, -#endif - { 0, 0, 0 }, { 1, 0, 0, 0 }); - - addManifoldContacts(ctx, manifold, ref_loc, other_loc); - } break; - default: MADRONA_UNREACHABLE(); - } -} - -static inline void runNarrowphase( - Context &ctx, - const CandidateCollision &candidate_collision - MADRONA_GPU_COND(, - const int32_t mwgpu_warp_id, - const int32_t mwgpu_lane_id, - bool lane_active)) -{ - PROF_START(setup_ctr, narrowphaseSetupClocks); - -#ifdef MADRONA_GPU_MODE - const int32_t num_smem_bytes_per_warp = - mwGPU::SharedMemStorage::numBytesPerWarp(); - const int32_t num_smem_floats = num_smem_bytes_per_warp / sizeof(float); - int32_t num_vertex_floats = num_smem_floats - gpuImpl::numPlaneFloats; - int32_t max_num_vertices = num_vertex_floats / 3; - - constexpr int32_t max_num_tmp_faces = gpuImpl::maxNumPlanes; - int32_t max_num_tmp_vertices = max_num_vertices; - - Plane tmp_faces_buffer[max_num_tmp_faces]; - - Plane * smem_faces_buffer; - Vector3 * smem_vertices_buffer; - { - auto smem_buf = (char *)mwGPU::SharedMemStorage::buffer; - char *warp_smem_base = - smem_buf + num_smem_bytes_per_warp * mwgpu_warp_id; - - smem_faces_buffer = (Plane *)warp_smem_base; - smem_vertices_buffer = - (Vector3 *)(smem_faces_buffer + gpuImpl::maxNumPlanes); - } -#else - constexpr int32_t max_num_tmp_faces = 512; - constexpr int32_t max_num_tmp_vertices = 512; - - Plane tmp_faces_buffer[max_num_tmp_faces]; - Vector3 tmp_vertices_buffer[max_num_tmp_vertices]; -#endif - - PROF_END(setup_ctr); - - PROF_START(prep_ctr, narrowphasePrepClocks); - - Loc a_loc = candidate_collision.a; - Loc b_loc = candidate_collision.b; - - const ObjectManager &obj_mgr = *ctx.singleton().mgr; - - uint32_t a_prim_idx, b_prim_idx; - { - ObjectID a_obj = ctx.getDirect(RGDCols::ObjectID, a_loc); - ObjectID b_obj = ctx.getDirect(RGDCols::ObjectID, b_loc); - - const uint32_t a_prim_offset = - obj_mgr.rigidBodyPrimitiveOffsets[a_obj.idx]; - const uint32_t b_prim_offset = - obj_mgr.rigidBodyPrimitiveOffsets[b_obj.idx]; - - a_prim_idx = a_prim_offset + candidate_collision.aPrim; - b_prim_idx = b_prim_offset + candidate_collision.bPrim; - } - - const CollisionPrimitive *a_prim = - &obj_mgr.collisionPrimitives[a_prim_idx]; - const CollisionPrimitive *b_prim = - &obj_mgr.collisionPrimitives[b_prim_idx]; - - uint32_t raw_type_a = static_cast(a_prim->type); - uint32_t raw_type_b = static_cast(b_prim->type); - - // Swap a & b to be properly ordered based on object type - if (raw_type_a > raw_type_b) { - std::swap(a_loc, b_loc); - std::swap(a_prim, b_prim); - std::swap(a_prim_idx, b_prim_idx); - std::swap(raw_type_a, raw_type_b); - } - - const Vector3 a_pos = ctx.getDirect(RGDCols::Position, a_loc); - const Vector3 b_pos = ctx.getDirect(RGDCols::Position, b_loc); - const Quat a_rot = ctx.getDirect(RGDCols::Rotation, a_loc); - const Quat b_rot = ctx.getDirect(RGDCols::Rotation, b_loc); - const Diag3x3 a_scale(ctx.getDirect(RGDCols::Scale, a_loc)); - const Diag3x3 b_scale(ctx.getDirect(RGDCols::Scale, b_loc)); - - { - AABB a_obj_aabb = obj_mgr.primitiveAABBs[a_prim_idx]; - AABB b_obj_aabb = obj_mgr.primitiveAABBs[b_prim_idx]; - - AABB a_world_aabb = a_obj_aabb.applyTRS(a_pos, a_rot, a_scale); - AABB b_world_aabb = b_obj_aabb.applyTRS(b_pos, b_rot, b_scale); - - if (!a_world_aabb.intersects(b_world_aabb)) { -#ifdef MADRONA_GPU_MODE - lane_active = false; -#else - return; -#endif - } - } - -#ifdef MADRONA_GPU_MODE - const uint32_t active_mask = __ballot_sync(mwGPU::allActive, lane_active); - - if (active_mask == 0) { - return; - } -#endif - - const NarrowphaseTest test_type {raw_type_a | raw_type_b}; - - PROF_END(prep_ctr); - -#ifdef MADRONA_GPU_MODE - NarrowphaseResult thread_result; - -#if 0 - active_mask = __brev(active_mask); - int32_t leader_idx = __clz(active_mask); - active_mask <<= (leader_idx + 1); - do { -#endif -#pragma unroll - for (int32_t leader_idx = 0; leader_idx < 32; leader_idx++) { - if (!__shfl_sync(mwGPU::allActive, lane_active, leader_idx)) { - continue; - } - - auto warp_test_type = (NarrowphaseTest)__shfl_sync( - mwGPU::allActive, (uint32_t)test_type, leader_idx); - - Vector3 warp_a_pos { - __shfl_sync(mwGPU::allActive, a_pos.x, leader_idx), - __shfl_sync(mwGPU::allActive, a_pos.y, leader_idx), - __shfl_sync(mwGPU::allActive, a_pos.z, leader_idx), - }; - - Vector3 warp_b_pos { - __shfl_sync(mwGPU::allActive, b_pos.x, leader_idx), - __shfl_sync(mwGPU::allActive, b_pos.y, leader_idx), - __shfl_sync(mwGPU::allActive, b_pos.z, leader_idx), - }; - - Quat warp_a_rot { - __shfl_sync(mwGPU::allActive, a_rot.w, leader_idx), - __shfl_sync(mwGPU::allActive, a_rot.x, leader_idx), - __shfl_sync(mwGPU::allActive, a_rot.y, leader_idx), - __shfl_sync(mwGPU::allActive, a_rot.z, leader_idx), - }; - - Quat warp_b_rot { - __shfl_sync(mwGPU::allActive, b_rot.w, leader_idx), - __shfl_sync(mwGPU::allActive, b_rot.x, leader_idx), - __shfl_sync(mwGPU::allActive, b_rot.y, leader_idx), - __shfl_sync(mwGPU::allActive, b_rot.z, leader_idx), - }; - - Diag3x3 warp_a_scale { - __shfl_sync(mwGPU::allActive, a_scale.d0, leader_idx), - __shfl_sync(mwGPU::allActive, a_scale.d1, leader_idx), - __shfl_sync(mwGPU::allActive, a_scale.d2, leader_idx), - }; - - Diag3x3 warp_b_scale { - __shfl_sync(mwGPU::allActive, b_scale.d0, leader_idx), - __shfl_sync(mwGPU::allActive, b_scale.d1, leader_idx), - __shfl_sync(mwGPU::allActive, b_scale.d2, leader_idx), - }; - - auto warp_a_prim = (CollisionPrimitive *)__shfl_sync(mwGPU::allActive, - (uint64_t)a_prim, leader_idx); - - auto warp_b_prim = (CollisionPrimitive *)__shfl_sync(mwGPU::allActive, - (uint64_t)b_prim, leader_idx); - - NarrowphaseResult warp_result = narrowphaseDispatch( - mwgpu_lane_id, - warp_test_type, - warp_a_pos, warp_b_pos, - warp_a_rot, warp_b_rot, - warp_a_scale, warp_b_scale, - warp_a_prim, warp_b_prim, - max_num_tmp_vertices, max_num_tmp_faces, - smem_vertices_buffer, smem_faces_buffer); - - if (mwgpu_lane_id == leader_idx) { - thread_result = warp_result; - } - -#if 0 - uint32_t num_inactive = __clz(active_mask); - leader_idx += num_inactive + 1; - active_mask <<= (num_inactive + 1); - } while (leader_idx < 32); -#endif - } - - __syncwarp(mwGPU::allActive); - - if (lane_active) { - generateContacts(ctx, thread_result, - a_loc, b_loc, - a_pos, a_rot, a_scale, - b_pos, b_rot, b_scale, - tmp_faces_buffer, - tmp_faces_buffer + max_num_tmp_faces / 2); - - } -#else - NarrowphaseResult result = narrowphaseDispatch( - test_type, - a_pos, b_pos, - a_rot, b_rot, - a_scale, b_scale, - a_prim, b_prim, - max_num_tmp_vertices, max_num_tmp_faces, - tmp_vertices_buffer, tmp_faces_buffer); - - generateContacts(ctx, result, - a_loc, b_loc, - tmp_faces_buffer, - tmp_faces_buffer + max_num_tmp_faces / 2); -#endif -} - -inline void runNarrowphaseSystem( -#ifdef MADRONA_GPU_MODE - WorldID *world_ids, - const CandidateCollision *candidate_collisions, - int32_t num_candidates -#else - Context &ctx, - const CandidateCollision &candidate_collision -#endif - ) -{ - PROF_START(all_ctr, narrowphaseAllClocks); -#ifdef MADRONA_GPU_MODE - const int32_t mwgpu_warp_id = threadIdx.x / 32; - const int32_t mwgpu_lane_id = threadIdx.x % 32; - - PROF_START(world_get_ctr, narrowphaseFetchWorldClocks); - - const int32_t candidate_idx = min(mwgpu_lane_id, num_candidates - 1); - - bool lane_active = candidate_idx == mwgpu_lane_id; - - WorldID world_id = world_ids[candidate_idx]; - if (world_id.idx == -1) { - lane_active = false; - } - - Context ctx = TaskGraph::makeContext(world_id); - PROF_END(world_get_ctr); - - runNarrowphase(ctx, candidate_collisions[candidate_idx], - mwgpu_warp_id, mwgpu_lane_id, lane_active); - -#else - runNarrowphase(ctx, candidate_collision); -#endif -} - -TaskGraphNodeID setupTasks( - TaskGraphBuilder &builder, - Span deps) -{ -#ifdef MADRONA_GPU_MODE - auto narrowphase = builder.addToGraph>(deps); -#else - auto narrowphase = builder.addToGraph>(deps); -#endif - - auto finished = builder.addToGraph({narrowphase}); - - return finished; -} - -} diff --git a/src/physics/physics.cpp b/src/physics/physics.cpp deleted file mode 100644 index e12f6cf8..00000000 --- a/src/physics/physics.cpp +++ /dev/null @@ -1,410 +0,0 @@ -#include -#include - -#include "physics_impl.hpp" -#include "xpbd.hpp" -#include "tgs.hpp" - -namespace madrona::phys { - -using namespace base; -using namespace math; - -#ifdef MADRONA_GPU_MODE -//#define COUNT_GPU_CLOCKS -#endif - -#ifdef COUNT_GPU_CLOCKS -extern "C" { -extern AtomicU64 narrowphaseAllClocks; -extern AtomicU64 narrowphaseFetchWorldClocks; -extern AtomicU64 narrowphaseSetupClocks; -extern AtomicU64 narrowphasePrepClocks; -extern AtomicU64 narrowphaseSwitchClocks; -extern AtomicU64 narrowphaseSATFaceClocks; -extern AtomicU64 narrowphaseSATEdgeClocks; -extern AtomicU64 narrowphaseSATPlaneClocks; -extern AtomicU64 narrowphaseSATContactClocks; -extern AtomicU64 narrowphaseSATPlaneContactClocks; -extern AtomicU64 narrowphaseSaveContactsClocks; -extern AtomicU64 narrowphaseTxfmHullCtrs; -} - -inline void reportNarrowphaseClocks(Engine &ctx, - SolverData &) -{ - if (ctx.worldID().idx != 0) { - return; - } - - if (threadIdx.x == 0 && ctx.worldID().idx == 0) { - printf("[%lu, %lu, %lu, %lu, %lu, %lu, %lu, %lu, %lu, %lu, %lu, %lu]\n", - narrowphaseAllClocks.load(), - narrowphaseFetchWorldClocks.load(), - narrowphaseSetupClocks.load(), - narrowphasePrepClocks.load(), - narrowphaseSwitchClocks.load(), - narrowphaseSATFaceClocks.load(), - narrowphaseSATEdgeClocks.load(), - narrowphaseSATPlaneClocks.load(), - narrowphaseSATContactClocks.load(), - narrowphaseSATPlaneContactClocks.load(), - narrowphaseSaveContactsClocks.load(), - narrowphaseTxfmHullCtrs.load() - ); - - narrowphaseAllClocks.store(0); - narrowphaseFetchWorldClocks.store(0); - narrowphaseSetupClocks.store(0), - narrowphasePrepClocks.store(0); - narrowphaseSwitchClocks.store(0); - narrowphaseSATFaceClocks.store(0); - narrowphaseSATEdgeClocks.store(0); - narrowphaseSATPlaneClocks.store(0); - narrowphaseSATContactClocks.store(0); - narrowphaseSATPlaneContactClocks.store(0); - narrowphaseSaveContactsClocks.store(0); - narrowphaseTxfmHullCtrs.store(0); - } -} -#endif - - -static void initPhysicsState(Context &ctx, - float delta_t, - CountT num_substeps, - Vector3 gravity, - uint32_t contact_archetype_id, - uint32_t joint_archetype_id) -{ - float h = delta_t / (float)num_substeps; - float g_mag = gravity.length(); - - ctx.singleton() = { - .deltaT = delta_t, - .h = h, - .g = gravity, - .gMagnitude = g_mag, - .restitutionThreshold = 2.f * g_mag * h, - .contactArchetypeID = contact_archetype_id, - .jointArchetypeID = joint_archetype_id, - }; -} - -namespace PhysicsSystem { - -void init(Context &ctx, - ObjectManager *obj_mgr, - float delta_t, - CountT num_substeps, - math::Vector3 gravity, - CountT max_dynamic_objects, - Solver solver) -{ - broadphase::BVH &bvh = ctx.singleton(); - - // expansion factor is 2 * delta_t to give room - // for acceleration within the timestep - constexpr float max_inst_accel = 100.f; - new (&bvh) broadphase::BVH( - obj_mgr, max_dynamic_objects, 2.f * delta_t, - max_inst_accel * delta_t * delta_t); - - uint32_t contact_archetype_id, joint_archetype_id; - switch (solver) { - case Solver::XPBD: { - xpbd::getSolverArchetypeIDs(&contact_archetype_id, - &joint_archetype_id); - } break; - case Solver::TGS: { - tgs::getSolverArchetypeIDs(&contact_archetype_id, - &joint_archetype_id); - } break; - default: MADRONA_UNREACHABLE(); - } - - initPhysicsState( - ctx, delta_t, num_substeps, gravity, - contact_archetype_id, joint_archetype_id); - - switch (solver) { - case Solver::XPBD: { - xpbd::init(ctx); - } break; - case Solver::TGS: { - tgs::init(ctx); - } break; - default: MADRONA_UNREACHABLE(); - } - - ctx.singleton() = { obj_mgr }; -} - -void reset(Context &ctx) -{ - broadphase::BVH &bvh = ctx.singleton(); - bvh.rebuildOnUpdate(); - bvh.clearLeaves(); -} - -broadphase::LeafID registerEntity(Context &ctx, - Entity e, - ObjectID obj_id) -{ - auto &bvh = ctx.singleton(); - - return bvh.reserveLeaf(e, obj_id); -} - -bool checkEntityAABBOverlap( - Context &ctx, math::AABB aabb, Entity e) -{ - const ObjectManager &obj_mgr = *ctx.singleton().mgr; - - ObjectID e_obj_id = ctx.get(e); - Position e_pos = ctx.get(e); - Rotation e_rot = ctx.get(e); - Scale e_scale = ctx.get(e); - - uint32_t num_prims = obj_mgr.rigidBodyPrimitiveCounts[e_obj_id.idx]; - uint32_t base_prim_offset = obj_mgr.rigidBodyPrimitiveOffsets[e_obj_id.idx]; - - bool overlap = false; - for (uint32_t prim_offset = 0; prim_offset < num_prims; prim_offset++) { - uint32_t prim_idx = base_prim_offset + prim_offset; - - const CollisionPrimitive &prim = obj_mgr.collisionPrimitives[prim_idx]; - if (prim.type != CollisionPrimitive::Type::Hull) { - continue; - } - - AABB prim_aabb = obj_mgr.primitiveAABBs[prim_idx]; - AABB txfmed_aabb = prim_aabb.applyTRS(e_pos, e_rot, e_scale); - - if (!txfmed_aabb.overlaps(aabb)) { - continue; - } - - const Vector3 *vertices = prim.hull.halfEdgeMesh.vertices; - CountT num_verts = (CountT)prim.hull.halfEdgeMesh.numVertices; - - const std::array axes { - right, - fwd, - up, - }; - - std::array min_hull_projs { - FLT_MAX, - FLT_MAX, - FLT_MAX, - }; - std::array max_hull_projs { - -FLT_MAX, - -FLT_MAX, - -FLT_MAX, - }; - - for (CountT vert_idx = 0; vert_idx < num_verts; vert_idx++) { - Vector3 v = - e_rot.rotateVec(e_scale * vertices[vert_idx]) + e_pos; - -#pragma unroll - for (CountT i = 0; i < 3; i++) { - Vector3 axis = axes[i]; - - float proj = dot(v, axis); - if (proj < min_hull_projs[i]) { - min_hull_projs[i] = proj; - } - - if (proj > max_hull_projs[i]) { - max_hull_projs[i] = proj; - } - } - } - - bool axes_overlap = true; - -#pragma unroll - for (CountT i = 0; i < 3; i++) { - float min_aabb_proj = aabb.pMin[i]; - float max_aabb_proj = aabb.pMax[i]; - - float min_hull_proj = min_hull_projs[i]; - float max_hull_proj = max_hull_projs[i]; - - bool proj_overlap = max_hull_proj > min_aabb_proj && - max_aabb_proj > min_hull_proj; - - if (!proj_overlap) { - axes_overlap = false; - } - } - - if (axes_overlap) { - overlap = true; - break; - } - } - - return overlap; -} - - -Entity makeFixedJoint( - Context &ctx, - Entity e1, Entity e2, - math::Quat attach_rot1, math::Quat attach_rot2, - math::Vector3 r1, math::Vector3 r2, - float separation) -{ - const auto &physics_sys = ctx.singleton(); - Entity e = ctx.makeEntity(physics_sys.jointArchetypeID); - - ctx.get(e) = { - .e1 = e1, - .e2 = e2, - .type = JointConstraint::Type::Fixed, - .fixed = { - .attachRot1 = attach_rot1, - .attachRot2 = attach_rot2, - .separation = separation, - }, - .r1 = r1, - .r2 = r2, - }; - - return e; -} - -Entity makeHingeJoint( - Context &ctx, - Entity e1, Entity e2, - math::Vector3 a1_local, math::Vector3 a2_local, - math::Vector3 b1_local, math::Vector3 b2_local, - math::Vector3 r1, math::Vector3 r2) -{ - const auto &physics_sys = ctx.singleton(); - Entity e = ctx.makeEntity(physics_sys.jointArchetypeID); - - ctx.get(e) = { - .e1 = e1, - .e2 = e2, - .type = JointConstraint::Type::Hinge, - .hinge = { - .a1Local = a1_local, - .a2Local = a2_local, - .b1Local = b1_local, - .b2Local = b2_local, - }, - .r1 = r1, - .r2 = r2, - }; - - return e; -} - -void registerTypes(ECSRegistry ®istry, - Solver solver) -{ - registry.registerComponent(); - registry.registerComponent(); - registry.registerComponent(); - registry.registerComponent(); - registry.registerComponent(); - - registry.registerSingleton(); - - registry.registerComponent(); - registry.registerArchetype(); - - registry.registerComponent(); - registry.registerArchetype(); - - registry.registerComponent(); - registry.registerComponent(); - - registry.registerSingleton(); - registry.registerSingleton(); - - switch (solver) { - case Solver::XPBD: { - xpbd::registerTypes(registry); - } break; - case Solver::TGS: { - tgs::registerTypes(registry); - } break; - default: MADRONA_UNREACHABLE(); - } - - registry.registerBundle(); -} - -TaskGraphNodeID setupBroadphaseTasks( - TaskGraphBuilder &builder, - Span deps) -{ - return broadphase::setupBVHTasks(builder, deps); -} - -TaskGraphNodeID setupPhysicsStepTasks( - TaskGraphBuilder &builder, - Span deps, - CountT num_substeps, - Solver solver) -{ - auto broadphase_prep = - broadphase::setupPreIntegrationTasks(builder, deps); - - TaskGraphNodeID solver_finished; - switch (solver) { - case Solver::XPBD: { - solver_finished = xpbd::setupXPBDSolverTasks( - builder, broadphase_prep, num_substeps); - } break; - case Solver::TGS: { - solver_finished = tgs::setupTGSSolverTasks( - builder, broadphase_prep, num_substeps); - } break; - default: MADRONA_UNREACHABLE(); - } - - auto broadphase_post = - broadphase::setupPostIntegrationTasks(builder, {solver_finished}); - - auto physics_done = broadphase_post; - -#ifdef COUNT_GPU_CLOCKS - physics_done = builder.addToGraph>({physics_done}); -#endif - - return physics_done; -} - -TaskGraphNodeID setupCleanupTasks( - TaskGraphBuilder &builder, Span deps) -{ - return builder.addToGraph>(deps); -} - - -TaskGraphNodeID setupStandaloneBroadphaseOverlapTasks( - TaskGraphBuilder &builder, - Span deps) -{ - return broadphase::setupPreIntegrationTasks(builder, deps); -} - -TaskGraphNodeID setupStandaloneBroadphaseCleanupTasks( - TaskGraphBuilder &builder, - Span deps) -{ - return builder.addToGraph>(deps); -} - - -} - -} diff --git a/src/physics/physics_assets.cpp b/src/physics/physics_assets.cpp deleted file mode 100644 index 93fc787e..00000000 --- a/src/physics/physics_assets.cpp +++ /dev/null @@ -1,1408 +0,0 @@ -#include -#include - -#ifdef MADRONA_CUDA_SUPPORT -#include -#endif - -#include - -namespace madrona::phys { -using namespace geo; -using namespace math; - -namespace { - -struct EditMesh { - struct HEdge { - uint32_t next; - uint32_t prev; - uint32_t twin; - - uint32_t vert; - uint32_t face; - }; - - struct Face { - uint32_t hedge; - uint32_t next; - uint32_t prev; - - Plane plane; - }; - - struct Vert { - Vector3 pos; - uint32_t next; - uint32_t prev; - }; - - HEdge *hedges; - Face *faces; - Vert *verts; - - uint32_t numHedges; - uint32_t numFaces; - uint32_t numVerts; - - uint32_t hedgeFreeHead; - uint32_t faceFreeHead; - uint32_t vertFreeHead; -}; - -struct HullBuildData { - EditMesh mesh; - uint32_t *faceConflictLists; -}; - -struct MassProperties { - Diag3x3 inertiaTensor; - Vector3 centerOfMass; - Quat toDiagonal; -}; - -} - -static uint32_t allocMeshHedge(EditMesh &mesh) -{ - uint32_t hedge = mesh.hedgeFreeHead; - assert(hedge != 0); - mesh.hedgeFreeHead = mesh.hedges[hedge].next; - - return hedge; -} - -static void freeMeshHedge(EditMesh &mesh, uint32_t hedge) -{ - uint32_t old_head = mesh.hedgeFreeHead; - mesh.hedgeFreeHead = hedge; - mesh.hedges[hedge].next = old_head; -} - -static uint32_t createMeshFace(EditMesh &mesh) -{ - uint32_t face = mesh.faceFreeHead; - assert(face != 0); - mesh.faceFreeHead = mesh.faces[face].next; - - uint32_t prev_prev = mesh.faces[0].prev; - mesh.faces[0].prev = face; - mesh.faces[prev_prev].next = face; - - mesh.faces[face].next = 0; - mesh.faces[face].prev = prev_prev; - - mesh.numFaces += -1; - - return face; -} - -static void deleteMeshFace(EditMesh &mesh, uint32_t face) -{ - uint32_t next = mesh.faces[face].next; - uint32_t prev = mesh.faces[face].prev; - - mesh.faces[prev].next = next; - mesh.faces[next].prev = prev; - - uint32_t old_head = mesh.faceFreeHead; - mesh.faceFreeHead = face; - mesh.faces[face].next = old_head; -} - -static uint32_t allocMeshVert(EditMesh &mesh) -{ - uint32_t vert = mesh.vertFreeHead; - assert(vert != 0); - mesh.vertFreeHead = mesh.verts[vert].next; - - return vert; -} - -static void freeMeshVert(EditMesh &mesh, uint32_t vert) -{ - uint32_t old_head = mesh.vertFreeHead; - mesh.vertFreeHead = vert; - mesh.verts[vert].next = old_head; -} - -static uint32_t addVertToMesh(EditMesh &mesh, uint32_t vert) -{ - uint32_t prev_prev = mesh.verts[0].prev; - mesh.verts[0].prev = vert; - mesh.verts[prev_prev].next = vert; - - mesh.verts[vert].next = 0; - mesh.verts[vert].prev = prev_prev; - - mesh.numVerts += 1; - - return vert; -} - -static void removeVertFromMesh(EditMesh &mesh, uint32_t vert) -{ - uint32_t next = mesh.verts[vert].next; - uint32_t prev = mesh.verts[vert].prev; - - mesh.verts[prev].next = next; - mesh.verts[next].prev = prev; - - mesh.numVerts -= 1; -} - -static uint32_t addConflictVert(HullBuildData &hull_data, - uint32_t face, - Vector3 pos) -{ - auto &mesh = hull_data.mesh; - uint32_t vert = allocMeshVert(mesh); - - uint32_t next = hull_data.faceConflictLists[face]; - - hull_data.faceConflictLists[face] = vert; - mesh.verts[vert].next = next; - mesh.verts[vert].prev = 0; - - if (next != 0) { - mesh.verts[next].prev = vert; - } - - mesh.verts[vert].pos = pos; - - return vert; -} - -static void removeConflictVert(HullBuildData &hull_data, - uint32_t face, - uint32_t vert) -{ - auto &mesh = hull_data.mesh; - - uint32_t next = mesh.verts[vert].next; - uint32_t prev = mesh.verts[vert].prev; - - if (prev == 0) { - hull_data.faceConflictLists[face] = next; - } else { - mesh.verts[prev].next = next; - } - - if (next != 0) { - mesh.verts[next].prev = prev; - } -} - -// Gregorious, Implementing QuickHull, GDC 2014, Slide 77 -static float computePlaneEpsilon(Span verts) -{ - AABB aabb = AABB::invalid(); - - for (Vector3 v : verts) { - aabb.expand(v); - } - - Vector3 diff = aabb.pMax - aabb.pMin; - - return 3.f * (diff.x + diff.y + diff.z) * FLT_EPSILON; -} - -// RTCD 12.4.2 -template -static Plane computeNewellPlaneImpl(Fn &&iter_verts) -{ - Vector3 centroid { 0, 0, 0 }; - Vector3 n { 0, 0, 0 }; - - CountT num_verts = 0; - // Compute normal as being proportional to projected areas of polygon - // onto the yz, xz, and xy planes. Also compute centroid as - // representative point on the plane - iter_verts([¢roid, &n, &num_verts](Vector3 vi, Vector3 vj) { - n.x += (vi.y - vj.y) * (vi.z + vj.z); // projection on yz - n.y += (vi.z - vj.z) * (vi.x + vj.x); // projection on xz - n.z += (vi.x - vj.x) * (vi.y + vj.y); // projection on xy - - centroid += vj; - num_verts += 1; - }); - - assert(num_verts != 0); - - centroid /= num_verts; - - n = normalize(n); - return Plane { - .normal = n, - .d = dot(centroid, n), - }; -} - -static Plane computeNewellPlane(const Vector3 *verts, - Span indices) -{ - return computeNewellPlaneImpl([verts, indices](auto &&fn) { - for (CountT i = indices.size() - 1, j = 0; j < indices.size(); - i = j, j++) { - Vector3 vi = verts[indices[i]]; - Vector3 vj = verts[indices[j]]; - - fn(vi, vj); - } - }); -} - -static Plane computeNewellPlane(EditMesh &mesh, uint32_t face) -{ - return computeNewellPlaneImpl([mesh, face](auto &&fn) { - uint32_t start_hedge_idx = mesh.faces[face].hedge; - uint32_t cur_hedge_idx = start_hedge_idx; - - do { - const EditMesh::HEdge &cur_hedge = mesh.hedges[cur_hedge_idx]; - uint32_t next_hedge_idx = cur_hedge.next; - const EditMesh::HEdge &next_hedge = mesh.hedges[next_hedge_idx]; - - uint32_t i = cur_hedge.vert; - uint32_t j = next_hedge.vert; - - fn(mesh.verts[i].pos, mesh.verts[j].pos); - - cur_hedge_idx = next_hedge_idx; - } while (cur_hedge_idx != start_hedge_idx); - }); -} - -static float distToPlane(Plane plane, Vector3 v) -{ - return v.dot(plane.normal) - plane.d; -} - -static HullBuildData allocBuildData(StackAlloc &tmp_alloc, const CountT N) -{ - // + 1 for fake starting point for linked lists - const CountT max_num_verts = N + 1; - // Num edges = 3N - 6. Doubled for half edges, doubled for horizon - const CountT max_num_hedges = 4 * (3 * N - 6) + 1; - // Num edges = 2N - 4. Doubled for horizon - const CountT max_num_faces = 2 * (2 * N - 4) + 1; - - const auto buffer_sizes = std::to_array({ - int64_t(sizeof(EditMesh::HEdge) * max_num_hedges), // hedges - int64_t(sizeof(EditMesh::Face) * max_num_faces), // faces - int64_t(sizeof(EditMesh::Vert) * max_num_verts), // verts - int64_t(sizeof(uint32_t) * max_num_faces), // faceConflictLists - }); - - constexpr CountT sub_buffer_alignment = 128; - - int64_t buffer_offsets[buffer_sizes.size() - 1]; - int64_t total_bytes = utils::computeBufferOffsets( - buffer_sizes, buffer_offsets, sub_buffer_alignment); - - char *buf_base = - (char *)tmp_alloc.alloc(total_bytes, sub_buffer_alignment); - - EditMesh mesh { - .hedges = (EditMesh::HEdge *)(buf_base), - .faces = (EditMesh::Face *)(buf_base + buffer_offsets[0]), - .verts = (EditMesh::Vert *)(buf_base + buffer_offsets[1]), - .numHedges = 0, - .numFaces = 0, - .numVerts = 0, - .hedgeFreeHead = 1, - .faceFreeHead = 1, - .vertFreeHead = 1, - }; - - // Setup free lists - for (CountT i = 1; i < max_num_hedges; i++) { - mesh.hedges[i].next = uint32_t(i + 1); - } - mesh.hedges[max_num_hedges].next = 0; - - for (CountT i = 1; i < max_num_faces; i++) { - mesh.faces[i].next = uint32_t(i + 1); - } - mesh.faces[max_num_faces].next = 0; - - for (CountT i = 1; i < max_num_verts; i++) { - mesh.verts[i].next = uint32_t(i + 1); - } - mesh.verts[max_num_verts].next = 0; - - // Elem 0 is fake head / tail to avoid special cases - mesh.hedges[0].next = 0; - mesh.hedges[0].prev = 0; - - mesh.faces[0].next = 0; - mesh.faces[0].prev = 0; - - mesh.verts[0].next = 0; - mesh.verts[0].prev = 0; - - uint32_t *face_conflict_lists = (uint32_t *)(buf_base + buffer_offsets[2]); - for (CountT i = 0; i < max_num_faces; i++) { - face_conflict_lists[i] = 0; - } - - return HullBuildData { - .mesh = mesh, - .faceConflictLists = face_conflict_lists, - }; -} - -static bool initHullTetrahedron(EditMesh &mesh, - Span verts, - float epsilon, - uint32_t *tet_fids, - Plane *tet_face_planes) -{ - // Choose the initial 4 points for the hull - Vector3 v0 = verts[0]; - - Vector3 v1, e1; - float max_v1_dist = -FLT_MAX; - for (CountT i = 1; i < verts.size(); i++) { - Vector3 v = verts[i]; - Vector3 e = v - v0; - float e_len = e.length(); - if (e_len > max_v1_dist) { - v1 = v; - e1 = e; - max_v1_dist = e_len; - } - } - - if (max_v1_dist < epsilon) { - return false; - } - - Vector3 v2, e2; - float max_v2_area = -FLT_MAX; - for (CountT i = 1; i < verts.size(); i++) { - Vector3 v = verts[i]; - Vector3 e = v - v0; - - float area = cross(e, e1).length(); - - if (area > max_v2_area) { - v2 = v; - e2 = e; - max_v2_area = area; - } - } - - if (max_v2_area < epsilon) { - return false; - } - - Vector3 v3; - float max_v3_det = -FLT_MAX; - for (CountT i = 1; i < verts.size(); i++) { - Vector3 v = verts[i]; - Vector3 e = v - v0; - - Mat3x3 vol_mat {{ e1, e2, e }}; - float det = vol_mat.determinant(); - - if (det > max_v3_det) { - v3 = v; - max_v3_det = det; - } - } - - if (max_v3_det < epsilon) { - return false; - } - - // Setup initial halfedge mesh - uint32_t vids[4]; - vids[0] = allocMeshVert(mesh); - vids[1] = allocMeshVert(mesh); - vids[2] = allocMeshVert(mesh); - vids[3] = allocMeshVert(mesh); - addVertToMesh(mesh, vids[0]); - addVertToMesh(mesh, vids[1]); - addVertToMesh(mesh, vids[2]); - addVertToMesh(mesh, vids[3]); - mesh.verts[vids[0]].pos = v0; - mesh.verts[vids[1]].pos = v1; - mesh.verts[vids[2]].pos = v2; - mesh.verts[vids[3]].pos = v3; - - // Face 0: - // he0: 3 => 2, he1: 2 => 1, he2: 1 => 3, - // Face 1: - // he3: 2 => 3, he4: 3 => 0, he5: 0 => 2, - // Face 2: - // he6: 1 => 0, he7: 0 => 3, he8: 3 => 1, - // Face 3: - // he9: 0 => 1, he10: 1 => 2, he11: 2 => 0, - uint32_t eids[12]; - const uint32_t face_vert_indices[] = { - 3, 2, 1, - 2, 3, 0, - 1, 0, 3, - 0, 1, 2 - }; - - const uint32_t twin_hedge_indices[] = { - 3, 10, 8, - 0, 7, 11, - 9, 4, 2, - 6, 1, 5, - }; - - // Allocate half edges -#pragma unroll - for (CountT i = 0; i < 12; i++) { - eids[i] = allocMeshHedge(mesh); - } - - // Create faces and create halfedges - for (CountT i = 0; i < 4; i++) { - const uint32_t base_hedge_offset = i * 3; - uint32_t fid = tet_fids[i] = createMeshFace(mesh); - -#pragma unroll - for (CountT j = 0; j < 3; j++) { - const uint32_t cur_hedge_offset = base_hedge_offset + j; - const uint32_t next_hedge_offset = base_hedge_offset + ((j + 1) % 3); - const uint32_t prev_hedge_offset = base_hedge_offset + ((j + 2) % 3); - - uint32_t vid = vids[face_vert_indices[cur_hedge_offset]]; - uint32_t cur_eid = eids[cur_hedge_offset]; - - mesh.hedges[cur_eid].face = fid; - mesh.hedges[cur_eid].vert = vid; - - mesh.hedges[cur_eid].next = eids[next_hedge_offset]; - mesh.hedges[cur_eid].prev = eids[prev_hedge_offset]; - - mesh.hedges[cur_eid].twin = twin_hedge_indices[cur_hedge_offset]; - } - - mesh.faces[fid].hedge = eids[base_hedge_offset]; - - Plane face_plane = computeNewellPlane(mesh, fid); - mesh.faces[fid].plane = tet_face_planes[i] = face_plane; - } - - return true; -} - -static bool initHullBuild(Span verts, - StackAlloc &tmp_alloc, - HullBuildData *out) -{ - if (verts.size() < 4) { - return false; - } - - *out = allocBuildData(tmp_alloc, verts.size()); - EditMesh &mesh = out->mesh; - - float epsilon = computePlaneEpsilon(verts); - - uint32_t tet_face_ids[4]; - Plane tet_face_planes[4]; - // FIXME: choose proper epsilon not just plane epsilon - bool tet_success = initHullTetrahedron(mesh, verts, epsilon, tet_face_ids, - tet_face_planes); - if (!tet_success) { - return false; - } - - // Initial vertex binning - for (Vector3 pos : verts) { - float closest_plane_dist = FLT_MAX; - CountT closest_plane_idx = -1; - for (CountT i = 0; i < 4; i++) { - Plane cur_plane = tet_face_planes[i]; - float dist = distToPlane(cur_plane, pos); - - if (dist > epsilon) { - if (dist < closest_plane_dist) { - closest_plane_idx = i; - closest_plane_dist = dist; - } - } - } - - // This is an internal vertex - if (closest_plane_idx == -1) { - continue; - } - - addConflictVert(*out, tet_face_ids[closest_plane_idx], pos); - } - - return true; -} - -static void quickhullBuild(HullBuildData &build_data) -{ - auto &mesh = build_data.mesh; - // FIXME - (void)mesh; - (void)freeMeshHedge; - (void)deleteMeshFace; - (void)freeMeshVert; - (void)removeVertFromMesh; - (void)removeConflictVert; -} - -static HalfEdgeMesh editMeshToRuntimeMesh(StackAlloc &tmp_alloc, - EditMesh &edit_mesh) -{ - uint32_t *hedge_remap = tmp_alloc.allocN(edit_mesh.numHedges); - uint32_t *face_remap = tmp_alloc.allocN(edit_mesh.numFaces); - uint32_t *vert_remap = tmp_alloc.allocN(edit_mesh.numVerts); - - for (CountT i = 0; i < edit_mesh.numHedges; i++) { - hedge_remap[i] = 0xFFFF'FFFF; - } - - CountT num_new_hedges = 0; - for (uint32_t orig_eid = edit_mesh.hedges[0].next; - orig_eid != 0; orig_eid = edit_mesh.hedges[orig_eid].next) { - if (hedge_remap[orig_eid] != 0xFFFF'FFFF) { - continue; - } - - const EditMesh::HEdge &cur_hedge = edit_mesh.hedges[orig_eid]; - uint32_t twin_eid = cur_hedge.twin; - assert(hedge_remap[twin_eid] = 0xFFFF'FFFF); - - hedge_remap[orig_eid] = num_new_hedges; - hedge_remap[twin_eid] = num_new_hedges + 1; - num_new_hedges += 2; - } - - CountT num_new_verts = 0; - for (uint32_t orig_vid = edit_mesh.verts[0].next; - orig_vid != 0; orig_vid = edit_mesh.verts[orig_vid].next) { - vert_remap[orig_vid] = num_new_verts++; - } - - CountT num_new_faces = 0; - for (uint32_t orig_fid = edit_mesh.faces[0].next; - orig_fid != 0; orig_fid = edit_mesh.faces[orig_fid].next) { - face_remap[orig_fid] = num_new_faces++; - } - - auto hedges_out = tmp_alloc.allocN(num_new_hedges); - auto face_base_hedges_out = tmp_alloc.allocN(num_new_faces); - auto face_planes_out = tmp_alloc.allocN(num_new_faces); - auto positions_out = tmp_alloc.allocN(num_new_verts); - - for (uint32_t orig_eid = edit_mesh.hedges[0].next; - orig_eid != 0; orig_eid = edit_mesh.hedges[orig_eid].next) { - const EditMesh::HEdge &orig_hedge = edit_mesh.hedges[orig_eid]; - - hedges_out[hedge_remap[orig_eid]] = HalfEdge { - .next = hedge_remap[orig_hedge.next], - .rootVertex = vert_remap[orig_hedge.vert], - .face = face_remap[orig_hedge.face], - }; - } - - for (uint32_t orig_vid = edit_mesh.verts[0].next; - orig_vid != 0; orig_vid = edit_mesh.verts[orig_vid].next) { - const EditMesh::Vert &orig_vert = edit_mesh.verts[orig_vid]; - positions_out[vert_remap[orig_vid]] = orig_vert.pos; - } - - for (uint32_t orig_fid = edit_mesh.faces[0].next; - orig_fid != 0; orig_fid = edit_mesh.faces[orig_fid].next) { - const EditMesh::Face &orig_face = edit_mesh.faces[orig_fid]; - - uint32_t new_face_idx = face_remap[orig_fid]; - - face_base_hedges_out[new_face_idx] = hedge_remap[orig_face.hedge]; - face_planes_out[new_face_idx] = orig_face.plane; - } - - return HalfEdgeMesh { - .halfEdges = hedges_out, - .faceBaseHalfEdges = face_base_hedges_out, - .facePlanes = face_planes_out, - .vertices = positions_out, - .numHalfEdges = uint32_t(num_new_hedges), - .numFaces = uint32_t(num_new_faces), - .numVertices = uint32_t(num_new_verts), - }; -} - -static inline HalfEdgeMesh buildHalfEdgeMesh( - StackAlloc &tmp_alloc, - const imp::SourceMesh &src_mesh) -{ - auto numFaceVerts = [&src_mesh](CountT face_idx) { - if (src_mesh.faceCounts == nullptr) { - return 3_u32; - } else { - return src_mesh.faceCounts[face_idx]; - } - }; - - using namespace madrona::math; - - uint32_t num_hedges = 0; - for (CountT face_idx = 0; face_idx < (CountT)src_mesh.numFaces; - face_idx++) { - num_hedges += numFaceVerts(face_idx); - } - - assert(num_hedges % 2 == 0); - - // We already know how many polygons there are - auto hedges_out = tmp_alloc.allocN(num_hedges); - auto face_base_hedges_out = tmp_alloc.allocN(src_mesh.numFaces); - auto face_planes_out = tmp_alloc.allocN(src_mesh.numFaces); - - std::unordered_map edge_to_hedge; - - auto makeEdgeID = [](uint32_t a_idx, uint32_t b_idx) { - return ((uint64_t)a_idx << 32) | (uint64_t)b_idx; - }; - - CountT num_assigned_hedges = 0; - const uint32_t *cur_face_indices = src_mesh.indices; - for (CountT face_idx = 0; face_idx < (CountT)src_mesh.numFaces; - face_idx++) { - CountT num_face_vertices = numFaceVerts(face_idx); - - Plane face_plane = computeNewellPlane(src_mesh.positions, - Span(cur_face_indices, num_face_vertices)); - - face_planes_out[face_idx] = face_plane; - - for (CountT vert_offset = 0; vert_offset < num_face_vertices; - vert_offset++) { - uint32_t a_idx = cur_face_indices[vert_offset]; - uint32_t b_idx = cur_face_indices[ - (vert_offset + 1) % num_face_vertices]; - - uint64_t cur_edge_id = makeEdgeID(a_idx, b_idx); - - auto cur_edge_lookup = edge_to_hedge.find(cur_edge_id); - if (cur_edge_lookup == edge_to_hedge.end()) { - uint32_t cur_hedge_id = num_assigned_hedges; - uint32_t twin_hedge_id = num_assigned_hedges + 1; - - num_assigned_hedges += 2; - - uint64_t twin_edge_id = makeEdgeID(b_idx, a_idx); - - auto [new_edge_iter, cur_inserted] = - edge_to_hedge.emplace(cur_edge_id, cur_hedge_id); - assert(cur_inserted); - - auto [new_twin_iter, twin_inserted] = - edge_to_hedge.emplace(twin_edge_id, twin_hedge_id); - assert(twin_inserted); - - cur_edge_lookup = new_edge_iter; - } - - uint32_t hedge_idx = cur_edge_lookup->second; - if (vert_offset == 0) { - face_base_hedges_out[face_idx] = hedge_idx; - } - - uint32_t c_idx = cur_face_indices[ - (vert_offset + 2) % num_face_vertices]; - - auto next_edge_id = makeEdgeID(b_idx, c_idx); - auto next_edge_lookup = edge_to_hedge.find(next_edge_id); - - // If next doesn't exist yet, we can assume it will be the next - // allocated half edge - uint32_t next_hedge_idx = next_edge_lookup == edge_to_hedge.end() ? - num_assigned_hedges : next_edge_lookup->second; - - hedges_out[hedge_idx] = HalfEdge { - .next = next_hedge_idx, - .rootVertex = a_idx, - .face = uint32_t(face_idx), - }; - } - - cur_face_indices += num_face_vertices; - } - - assert(num_assigned_hedges == num_hedges); - - return HalfEdgeMesh { - .halfEdges = hedges_out, - .faceBaseHalfEdges = face_base_hedges_out, - .facePlanes = face_planes_out, - .vertices = src_mesh.positions, - .numHalfEdges = uint32_t(num_hedges), - .numFaces = src_mesh.numFaces, - .numVertices = src_mesh.numVertices, - }; -} - -static bool processConvexHull(const imp::SourceMesh &src_mesh, - bool build_hull, - StackAlloc &tmp_alloc, - HalfEdgeMesh *out_mesh) -{ - if (!build_hull) { - // Just assume the input geometry is a convex hull with coplanar faces - // merged - *out_mesh = buildHalfEdgeMesh(tmp_alloc, src_mesh); - } else { - HullBuildData hull_data; - bool valid_input = initHullBuild( - Span(src_mesh.positions, src_mesh.numVertices), tmp_alloc, - &hull_data); - - if (!valid_input) { - return false; - } - - quickhullBuild(hull_data); - - *out_mesh = editMeshToRuntimeMesh(tmp_alloc, hull_data.mesh); - } - - return true; -} - -static bool processConvexHulls( - Span in_meshes, - bool build_convex_hulls, - StackAlloc &tmp_alloc, - HalfEdgeMesh *out_meshes) -{ - for (CountT hull_idx = 0; hull_idx < in_meshes.size(); hull_idx++) { - const imp::SourceMesh &mesh = in_meshes[hull_idx]; - bool success = processConvexHull( - mesh, build_convex_hulls, tmp_alloc, &out_meshes[hull_idx]); - - if (!success) { - return false; - } - } - - return true; -} - -// Below functions diagonalize the inertia tensor and compute the necessary -// rotation for diagonalization as a quaternion. -// Source: Computing the Singular Value Decomposition of 3x3 matrices with -// minimal branching and elementary floating point operations. -// McAdams et al 2011 - -// McAdams Algorithm 2: -static std::pair approxGivensQuaternion(Symmetric3x3 m) -{ - - constexpr float gamma = 5.82842712474619f; - constexpr float c_star = 0.9238795325112867f; - constexpr float s_star = 0.3826834323650898f; - - float a11 = m.diag[0], a12 = m.off[0], a22 = m.diag[1]; - - float ch = 2.f * (a11 - a22); - float sh = a12; - - float sh2 = sh * sh; - - // This isn't in the paper, but basically want to make sure the quaternion - // performs an identity rotation for already diagonal matrices - if (sh2 < 1e-20f) { - return { 1.f, 0.f }; - } - - float ch2 = ch * ch; - - bool b = (gamma * sh2) < ch2; - - float omega = rsqrtApprox(ch2 + sh2); - - ch = b ? (omega * ch) : c_star; - sh = b ? (omega * sh) : s_star; - - return { ch, sh }; -} - -// Equation 12: approxGivensQuaternion returns an unscaled quaternion, -// need to rescale -static Symmetric3x3 jacobiIterConjugation(Symmetric3x3 m, float ch, float sh) -{ - float ch2 = ch * ch; - float sh2 = sh * sh; - float q_scale = ch2 + sh2; - - float q11 = (ch2 - sh2) / q_scale; - float q12 = (-2.f * sh * ch) / q_scale; - float q21 = (2.f * sh * ch) / q_scale; - float q22 = (ch2 - sh2) / q_scale; - - // Output = Q^T * m * Q. Given above values for Q, direct solution to - // compute output (given 0s for other terms) computed using SymPy - - auto [m11, m22, m33] = m.diag; - auto [m12, m13, m23] = m.off; - - float m11q11_m12q21 = m11 * q11 + m12 * q21; - float m11q12_m12q22 = m11 * q12 + m12 * q22; - - float m12q11_m22q21 = m12 * q11 + m22 * q21; - float m12q12_m22q22 = m12 * q12 + m22 * q22; - - return Symmetric3x3 { - .diag = { - q11 * m11q11_m12q21 + q21 * m12q11_m22q21, - q12 * m11q12_m12q22 + q22 * m12q12_m22q22, - m33, - }, - .off = { - q12 * m11q11_m12q21 + q22 * m12q11_m22q21, - m13 * q11 + m23 * q21, - m13 * q12 + m23 * q22, - }, - }; -} - -// Inertia tensor is symmetric positive semi definite, so we only need to -// perform the symmetric eigenanalysis part of the algorithm. -// -// Jacobi order: (p, q) = (1, 2), (1, 3), (2, 3), (1, 2), (1, 3) ... -// Pairs: (1, 2) = (a11, a22, a12); (1, 3) = (a11, a33, a13); -// (2, 3) = (a22, a33, a23) -static void diagonalizeInertiaTensor(const Symmetric3x3 &m, - Diag3x3 *out_diag, - Quat *out_rot) -{ - using namespace math; - - constexpr CountT num_jacobi_iters = 8; - - Symmetric3x3 cur_mat = m; - Quat accumulated_rot { 1, 0, 0, 0 }; - for (CountT i = 0; i < num_jacobi_iters; i++) { -#if 0 - printf("Cur:\n" - "%f %f %f\n" - "%f %f %f\n" - "%f %f %f\n", - cur_mat[0].x, cur_mat[1].x, cur_mat[2].x, - cur_mat[0].y, cur_mat[1].y, cur_mat[2].y, - cur_mat[0].z, cur_mat[1].z, cur_mat[2].z); -#endif - - auto [ch1, sh1] = approxGivensQuaternion(cur_mat); - cur_mat = jacobiIterConjugation(cur_mat, ch1, sh1); - - // Rearrange matrix so unrotated elements are in upper left corner - std::swap(cur_mat.diag[1], cur_mat.diag[2]); - std::swap(cur_mat.off[0], cur_mat.off[1]); - - auto [ch2, sh2] = approxGivensQuaternion(cur_mat); - cur_mat = jacobiIterConjugation(cur_mat, ch2, sh2); - - std::swap(cur_mat.diag[0], cur_mat.diag[2]); - std::swap(cur_mat.off[0], cur_mat.off[2]); - - auto [ch3, sh3] = approxGivensQuaternion(cur_mat); - cur_mat = jacobiIterConjugation(cur_mat, ch3, sh3); - - cur_mat = Symmetric3x3 { - .diag = { cur_mat.diag[2], cur_mat.diag[0], cur_mat.diag[1] }, - .off = { cur_mat.off[1], cur_mat.off[2], cur_mat.off[0] }, - }; - - // This could be optimized - accumulated_rot = Quat { ch1, 0, 0, sh1 } * Quat { ch2, 0, sh2, 0 } * - Quat { ch3, sh3, 0, 0 } * accumulated_rot; - } - - Quat final_rot = accumulated_rot.normalize(); - - // Compute the diagonal (all other terms should be ~0) - { - Mat3x3 q = Mat3x3::fromQuat(final_rot); - - auto [m11, m22, m33] = m.diag; - auto [m12, m13, m23] = m.off; - - auto [q11, q21, q31] = q[0]; - auto [q12, q22, q32] = q[1]; - auto [q13, q23, q33] = q[2]; - - out_diag->d0 = q11 * (m11 * q11 + m12 * q21 + m13 * q31) + - q21 * (m12 * q11 + m22 * q21 + m23 * q31) + - q31 * (m13 * q11 + m23 * q21 + m33 * q31); - - out_diag->d1 = q12 * (m11 * q12 + m12 * q22 + m13 * q32) + - q22 * (m12 * q12 + m22 * q22 + m23 * q32) + - q32 * (m13 * q12 + m23 * q22 + m33 * q32); - - out_diag->d2 = q13 * (m11 * q13 + m12 * q23 + m13 * q33) + - q23 * (m12 * q13 + m22 * q23 + m23 * q33) + - q33 * (m13 * q13 + m23 * q23 + m33 * q33); - } - - *out_rot = final_rot; -} - -// http://number-none.com/blow/inertia/ -static inline MassProperties computeMassProperties( - const HalfEdgeMesh *convex_hulls, - const SourceCollisionObject &src_obj) -{ - using namespace math; - const Symmetric3x3 C_canonical { - .diag = Vector3 { 1.f / 60.f, 1.f / 60.f, 1.f / 60.f }, - .off = Vector3 { 1.f / 120.f, 1.f / 120.f, 1.f / 120.f }, - }; - constexpr float density = 1.f; - - Symmetric3x3 C_total { - .diag = Vector3::zero(), - .off = Vector3::zero(), - }; - - float m_total = 0; - Vector3 x_total = Vector3::zero(); - - auto processTet = [&](Vector3 v1, Vector3 v2, Vector3 v3) { - // Reference point is (0, 0, 0) so tet edges are just the vertex - // positions - Vector3 e1 = v1; - Vector3 e2 = v2; - Vector3 e3 = v3; - - // Covariance matrix - Mat3x3 A {{ e1, e2, e3 }}; - float det_A = A.determinant(); - Symmetric3x3 C = det_A * Symmetric3x3::AXAT(A, C_canonical); - - // Mass - float volume = 1.f / 6.f * det_A; - float m = volume * density; - - Vector3 x = 0.25f * e1 + 0.25f * e2 + 0.25f * e3; - - // Accumulate tetrahedron properties - float old_m_total = m_total; - m_total += m; - x_total = (x * m + x_total * old_m_total) / m_total; - - C_total += C; - }; - - for (const SourceCollisionPrimitive &prim : src_obj.prims) { - if (prim.type == CollisionPrimitive::Type::Sphere) { - // FIXME: need to allow offset for primitives - m_total += 1.f; - - float r = prim.sphere.radius; - - // Note that we need the sphere's covariance matrix, - // not the inertia tensor (hence 1/2 standard formulas) - float v = 1.f / 5.f * r * r; - C_total += Symmetric3x3 { - .diag = Vector3 { v, v, v }, - .off = Vector3::zero(), - }; - continue; - } else if (prim.type == CollisionPrimitive::Type::Plane) { - // Plane has infinite mass / inertia. The rest of the - // object must as well - - return MassProperties { - Diag3x3::uniform(INFINITY), - Vector3::zero(), - Quat { 1, 0, 0, 0 }, - }; - } - - // Hull primitive - - const HalfEdgeMesh &convex_hull = convex_hulls[prim.hullInput.hullIDX]; - - for (CountT face_idx = 0; face_idx < (CountT)convex_hull.numFaces; - face_idx++) { - uint32_t root_hedge_idx = convex_hull.faceBaseHalfEdges[face_idx]; - HalfEdge root_hedge = convex_hull.halfEdges[root_hedge_idx]; - Vector3 v1 = convex_hull.vertices[root_hedge.rootVertex]; - uint32_t cur_hedge_idx = root_hedge.next; - - while (true) { - HalfEdge cur_hedge = convex_hull.halfEdges[cur_hedge_idx]; - uint32_t next_hedge_idx = cur_hedge.next; - if (next_hedge_idx == root_hedge_idx) { - break; - } - - HalfEdge next_hedge = convex_hull.halfEdges[next_hedge_idx]; - - Vector3 v2 = convex_hull.vertices[cur_hedge.rootVertex]; - Vector3 v3 = convex_hull.vertices[next_hedge.rootVertex]; - - processTet(v1, v2, v3); - - cur_hedge_idx = next_hedge_idx; - } - } - } - - auto translateCovariance = [](const Symmetric3x3 &C, - Vector3 x, // COM - float m, - Vector3 delta_x) { - Symmetric3x3 delta_xxT_plus_xdeltaxT { - .diag = 2.f * Vector3 { - x.x * delta_x.x, - x.y * delta_x.y, - x.z * delta_x.z, - }, - .off = Vector3 { - x.x * delta_x.y + x.y * delta_x.x, - x.x * delta_x.z + x.z * delta_x.x, - x.y * delta_x.z + x.z * delta_x.y, - }, - }; - - Symmetric3x3 delta_xdelta_xT = Symmetric3x3::vvT(delta_x); - return C + m * (delta_xxT_plus_xdeltaxT + delta_xdelta_xT); - }; - - // Move accumulated covariance matrix to center of mass - C_total = translateCovariance(C_total, x_total, m_total, -x_total); - - float tr_C = C_total[0][0] + C_total[1][1] + C_total[2][2]; - const Symmetric3x3 tr_C_diag { - .diag = Vector3 { tr_C, tr_C, tr_C }, - .off = Vector3::zero(), - }; - - // Compute inertia tensor - Symmetric3x3 inertia_tensor = tr_C_diag - C_total; - - // Rescale total mass of inertia tensor (unless infinity) - float inv_mass = 1.f / m_total; - inertia_tensor *= inv_mass; - -#if 0 - printf("Inertia Tensor:\n" - "%f %f %f\n" - "%f %f %f\n" - "%f %f %f\n" - "COM: (%f %f %f) mass: %f\n", - inertia_tensor[0].x, - inertia_tensor[1].x, - inertia_tensor[2].x, - inertia_tensor[0].y, - inertia_tensor[1].y, - inertia_tensor[2].y, - inertia_tensor[0].z, - inertia_tensor[1].z, - inertia_tensor[2].z, - x_total.x, - x_total.y, - x_total.z, - m_total - ); -#endif - - Diag3x3 diag_inertia; - Quat rot_to_diag; - diagonalizeInertiaTensor(inertia_tensor, &diag_inertia, &rot_to_diag); - -#if 0 - printf("Diag Inertia tensor: (%f %f %f) rot: (%f %f %f %f)\n\n", - diag_inertia.d0, diag_inertia.d1, diag_inertia.d2, - rot_to_diag.w, - rot_to_diag.x, - rot_to_diag.y, - rot_to_diag.z); -#endif - - return MassProperties { - diag_inertia, - x_total, - rot_to_diag, - }; -} - -static inline RigidBodyMassData toMassData(const MassProperties &mass_props, - float inv_m) -{ - Diag3x3 inv_inertia = inv_m / mass_props.inertiaTensor; - - return { - .invMass = inv_m, - .invInertiaTensor = Vector3 { // FIXME - inv_inertia.d0, - inv_inertia.d1, - inv_inertia.d2, - }, - .toCenterOfMass = mass_props.centerOfMass, - .toInteriaFrame = mass_props.toDiagonal, - }; -} - -static void computeRigidBodiesMetadata( - const HalfEdgeMesh *convex_hulls, - Span collision_objs, - RigidBodyMetadata *out_metadatas) -{ - for (CountT obj_idx = 0; obj_idx < collision_objs.size(); obj_idx++) { - const SourceCollisionObject &collision_obj = collision_objs[obj_idx]; - - MassProperties mass_props = computeMassProperties( - convex_hulls, collision_obj); - - out_metadatas[obj_idx] = RigidBodyMetadata { - .mass = toMassData(mass_props, collision_obj.invMass), - .friction = collision_obj.friction, - }; - } -} - -static void setupSpherePrimitive(const SourceCollisionPrimitive &src_prim, - CollisionPrimitive *out_prim, - AABB *out_aabb) -{ - out_prim->sphere = src_prim.sphere; - - const float r = src_prim.sphere.radius; - - *out_aabb = AABB { - .pMin = { -r, -r, -r }, - .pMax = { r, r, r }, - }; -} - -static void setupPlanePrimitive(const SourceCollisionPrimitive &, - CollisionPrimitive *out_prim, - AABB *out_aabb) -{ - out_prim->plane = CollisionPrimitive::Plane {}; - - *out_aabb = AABB { - .pMin = { -FLT_MAX, -FLT_MAX, -FLT_MAX }, - .pMax = { FLT_MAX, FLT_MAX, 0 }, - }; -} - -static void setupHullPrimitive(const SourceCollisionPrimitive &src_prim, - const HalfEdgeMesh *hull_meshes, - CollisionPrimitive *out_prim, - AABB *out_aabb) -{ - const HalfEdgeMesh &hull_mesh = hull_meshes[src_prim.hullInput.hullIDX]; - - AABB mesh_aabb = AABB::point(hull_mesh.vertices[0]); - for (CountT vert_idx = 1; vert_idx < (CountT)hull_mesh.numVertices; - vert_idx++) { - mesh_aabb.expand(hull_mesh.vertices[vert_idx]); - } - - out_prim->hull.halfEdgeMesh = hull_mesh; - *out_aabb = mesh_aabb; -} - -static void setupRigidBodyAABBsAndPrimitives( - HalfEdgeMesh *hull_meshes, - Span collision_objs, - CollisionPrimitive *out_prims, - AABB *out_prim_aabbs, - AABB *out_obj_aabbs, - uint32_t *out_prim_offsets, - uint32_t *out_prim_counts) -{ - using Type = CollisionPrimitive::Type; - - uint32_t cur_prim_offset = 0; - for (CountT obj_idx = 0; obj_idx < collision_objs.size(); obj_idx++) { - const SourceCollisionObject &collision_obj = collision_objs[obj_idx]; - - CountT num_prims = collision_obj.prims.size(); - CollisionPrimitive *obj_prims = out_prims + cur_prim_offset; - AABB *prim_aabbs = out_prim_aabbs + cur_prim_offset; - - auto obj_aabb = AABB::invalid(); - - for (CountT prim_idx = 0; prim_idx < num_prims; prim_idx++) { - const SourceCollisionPrimitive &src_prim = - collision_obj.prims[prim_idx]; - - CollisionPrimitive *out_prim = &obj_prims[prim_idx]; - out_prim->type = src_prim.type; - AABB prim_aabb; - - switch (src_prim.type) { - case Type::Sphere: { - setupSpherePrimitive(src_prim, out_prim, &prim_aabb); - } break; - case Type::Plane: { - setupPlanePrimitive(src_prim, out_prim, &prim_aabb); - } break; - case Type::Hull: { - setupHullPrimitive(src_prim, hull_meshes, - out_prim, &prim_aabb); - } break; - } - - prim_aabbs[prim_idx] = prim_aabb; - obj_aabb = AABB::merge(obj_aabb, prim_aabb); - } - - out_obj_aabbs[obj_idx] = obj_aabb; - out_prim_offsets[obj_idx] = cur_prim_offset; - out_prim_counts[obj_idx] = (uint32_t)num_prims; - - cur_prim_offset += (uint32_t)num_prims; - } -} - -void * RigidBodyAssets::processRigidBodyAssets( - Span convex_hull_meshes, - Span collision_objs, - bool build_convex_hulls, - StackAlloc &tmp_alloc, - RigidBodyAssets *out_assets, - CountT *out_num_bytes) -{ - auto tmp_frame = tmp_alloc.push(); - - HalfEdgeMesh *built_hulls = - tmp_alloc.allocN(convex_hull_meshes.size()); - - auto hull_build_frame = tmp_alloc.push(); - - bool hull_success = processConvexHulls(convex_hull_meshes, - build_convex_hulls, - tmp_alloc, - built_hulls); - - if (!hull_success) { - tmp_alloc.pop(hull_build_frame); - tmp_alloc.pop(tmp_frame); - return nullptr; - } - - CountT total_num_prims = 0; - for (CountT obj_idx = 0; obj_idx < collision_objs.size(); obj_idx++) { - const SourceCollisionObject &collision_obj = collision_objs[obj_idx]; - CountT cur_num_prims = collision_obj.prims.size(); - total_num_prims += cur_num_prims; - } - - CountT total_num_halfedges = 0; - CountT total_num_faces = 0; - CountT total_num_verts = 0; - for (CountT hull_idx = 0; hull_idx < convex_hull_meshes.size(); - hull_idx++) { - const HalfEdgeMesh &hull_mesh = built_hulls[hull_idx]; - - total_num_halfedges += hull_mesh.numHalfEdges; - total_num_faces += hull_mesh.numFaces; - total_num_verts += hull_mesh.numVertices; - } - - auto buffer_sizes = std::to_array({ - (int64_t)sizeof(HalfEdge) * total_num_halfedges, // halfEdges - (int64_t)sizeof(uint32_t) * total_num_faces, // faceBaseHalfEdges - (int64_t)sizeof(Plane) * total_num_faces, // facePlanes - (int64_t)sizeof(Vector3) * total_num_verts, // vertices - (int64_t)sizeof(CollisionPrimitive) * total_num_prims, // prims - (int64_t)sizeof(AABB) * total_num_prims, // primAABBs - (int64_t)sizeof(RigidBodyMetadata) * - collision_objs.size(), // metadatas - (int64_t)sizeof(AABB) * - collision_objs.size(), // obj_aabbs - (int64_t)sizeof(uint32_t) * - collision_objs.size(), // prim_offsets - (int64_t)sizeof(uint32_t) * - collision_objs.size(), // prim_counts - }); - - int64_t buffer_offsets[buffer_sizes.size() - 1]; - int64_t num_buffer_bytes = utils::computeBufferOffsets( - buffer_sizes, buffer_offsets, 64); - - char *buffer = (char *)malloc(num_buffer_bytes); - RigidBodyAssets assets { - .hullData = { - .halfEdges = (HalfEdge *)buffer, - .faceBaseHalfEdges = (uint32_t *)(buffer + buffer_offsets[0]), - .facePlanes = (Plane *)(buffer + buffer_offsets[1]), - .vertices = (Vector3 *)(buffer + buffer_offsets[2]), - .numHalfEdges = (uint32_t)total_num_halfedges, - .numFaces = (uint32_t)total_num_faces, - .numVerts = (uint32_t)total_num_verts, - }, - .primitives = (CollisionPrimitive *)(buffer + buffer_offsets[3]), - .primitiveAABBs = (AABB *)(buffer + buffer_offsets[4]), - .metadatas = (RigidBodyMetadata *)(buffer + buffer_offsets[5]), - .objAABBs = (AABB *)(buffer + buffer_offsets[6]), - .primOffsets = (uint32_t *)(buffer + buffer_offsets[7]), - .primCounts = (uint32_t *)(buffer + buffer_offsets[8]), - .numConvexHulls = (uint32_t)convex_hull_meshes.size(), - .totalNumPrimitives = (uint32_t)total_num_prims, - .numObjs = (uint32_t)collision_objs.size(), - }; - - CountT cur_halfedge_offset = 0; - CountT cur_face_offset = 0; - CountT cur_vert_offset = 0; - for (CountT hull_idx = 0; hull_idx < convex_hull_meshes.size(); - hull_idx++) { - HalfEdgeMesh &hull_mesh = built_hulls[hull_idx]; - - HalfEdge *he_out = &assets.hullData.halfEdges[cur_halfedge_offset]; - uint32_t *face_bases_out = - &assets.hullData.faceBaseHalfEdges[cur_face_offset]; - Plane *face_planes_out = &assets.hullData.facePlanes[cur_face_offset]; - Vector3 *verts_out = &assets.hullData.vertices[cur_vert_offset]; - - memcpy(he_out, hull_mesh.halfEdges, - sizeof(HalfEdge) * hull_mesh.numHalfEdges); - memcpy(face_bases_out, hull_mesh.faceBaseHalfEdges, - sizeof(uint32_t) * hull_mesh.numFaces); - memcpy(face_planes_out, hull_mesh.facePlanes, - sizeof(Plane) * hull_mesh.numFaces); - memcpy(verts_out, hull_mesh.vertices, - sizeof(Vector3) * hull_mesh.numVertices); - - hull_mesh.halfEdges = he_out; - hull_mesh.faceBaseHalfEdges = face_bases_out; - hull_mesh.facePlanes = face_planes_out; - hull_mesh.vertices = verts_out; - - cur_halfedge_offset += hull_mesh.numHalfEdges; - cur_face_offset += hull_mesh.numFaces; - cur_vert_offset += hull_mesh.numVertices; - } - - tmp_alloc.pop(hull_build_frame); - - setupRigidBodyAABBsAndPrimitives(built_hulls, - collision_objs, - assets.primitives, - assets.primitiveAABBs, - assets.objAABBs, - assets.primOffsets, - assets.primCounts); - - computeRigidBodiesMetadata( - built_hulls, collision_objs, assets.metadatas); - - tmp_alloc.pop(tmp_frame); - - *out_assets = assets; - *out_num_bytes = num_buffer_bytes; - return buffer; -} - -} diff --git a/src/physics/physics_impl.hpp b/src/physics/physics_impl.hpp deleted file mode 100644 index 1c5ee7ed..00000000 --- a/src/physics/physics_impl.hpp +++ /dev/null @@ -1,60 +0,0 @@ -#pragma once - -#include - -namespace madrona::phys { - -struct PhysicsSystemState { - float deltaT; - float h; - math::Vector3 g; - float gMagnitude; - float restitutionThreshold; - uint32_t contactArchetypeID; - uint32_t jointArchetypeID; -}; - -struct CandidateTemporary : Archetype {}; - -namespace broadphase { - -TaskGraphNodeID setupBVHTasks( - TaskGraphBuilder &builder, - Span deps); - -TaskGraphNodeID setupPreIntegrationTasks( - TaskGraphBuilder &builder, - Span deps); - -TaskGraphNodeID setupPostIntegrationTasks( - TaskGraphBuilder &builder, - Span deps); - -} - -namespace narrowphase { - -TaskGraphNodeID setupTasks( - TaskGraphBuilder &builder, - Span deps); - -} - -namespace RGDCols { - constexpr inline CountT Position = 2; - constexpr inline CountT Rotation = 3; - constexpr inline CountT Scale = 4; - constexpr inline CountT ObjectID = 5; - constexpr inline CountT ResponseType = 6; - constexpr inline CountT LeafID = 7; - constexpr inline CountT Velocity = 8; - constexpr inline CountT ExternalForce = 9; - constexpr inline CountT ExternalTorque = 10; - constexpr inline CountT SolverBase = 11; - - constexpr inline CountT CandidateCollision = 2; - constexpr inline CountT ContactConstraint = 2; - constexpr inline CountT JointConstraint = 2; -}; - -} diff --git a/src/physics/physics_loader.cpp b/src/physics/physics_loader.cpp deleted file mode 100644 index 7e65f155..00000000 --- a/src/physics/physics_loader.cpp +++ /dev/null @@ -1,341 +0,0 @@ -#include -#include - -#ifdef MADRONA_CUDA_SUPPORT -#include -#endif - -#include - -namespace madrona::phys { -using namespace geo; -using namespace math; - -#ifndef MADRONA_CUDA_SUPPORT -[[noreturn]] static void noCUDA() -{ - FATAL("PhysicsLoader: Not built with CUDA support"); -} -#endif - -struct PhysicsLoader::Impl { - CollisionPrimitive *primitives; - AABB *primAABBs; - - AABB *objAABBs; - uint32_t *rigidBodyPrimitiveOffsets; - uint32_t *rigidBodyPrimitiveCounts; - RigidBodyMetadata *metadatas; - - CountT curPrimOffset; - CountT curObjOffset; - - ObjectManager *mgr; - CountT maxPrims; - CountT maxObjs; - ExecMode execMode; - - static Impl * init(ExecMode exec_mode, CountT max_objects) - { - constexpr CountT max_prims_per_object = 20; - - size_t num_collision_prim_bytes = - sizeof(CollisionPrimitive) * max_objects * max_prims_per_object; - - size_t num_collision_aabb_bytes = - sizeof(AABB) * max_objects * max_prims_per_object; - - size_t num_obj_aabb_bytes = - sizeof(AABB) * max_objects; - - size_t num_offset_bytes = - sizeof(uint32_t) * max_objects; - - size_t num_count_bytes = - sizeof(uint32_t) * max_objects; - - size_t num_metadata_bytes = - sizeof(RigidBodyMetadata) * max_objects; - - CollisionPrimitive *primitives_ptr; - AABB *prim_aabb_ptr; - - AABB *obj_aabb_ptr; - uint32_t *offsets_ptr; - uint32_t *counts_ptr; - RigidBodyMetadata *metadata_ptr; - - ObjectManager *mgr; - - switch (exec_mode) { - case ExecMode::CPU: { - primitives_ptr = (CollisionPrimitive *)malloc( - num_collision_prim_bytes); - - prim_aabb_ptr = (AABB *)malloc( - num_collision_aabb_bytes); - - obj_aabb_ptr = (AABB *)malloc(num_obj_aabb_bytes); - - offsets_ptr = (uint32_t *)malloc(num_offset_bytes); - counts_ptr = (uint32_t *)malloc(num_count_bytes); - - metadata_ptr = - (RigidBodyMetadata *)malloc(num_metadata_bytes); - - mgr = new ObjectManager { - primitives_ptr, - prim_aabb_ptr, - obj_aabb_ptr, - offsets_ptr, - counts_ptr, - metadata_ptr, - }; - } break; - case ExecMode::CUDA: { -#ifndef MADRONA_CUDA_SUPPORT - noCUDA(); -#else - primitives_ptr = (CollisionPrimitive *)cu::allocGPU( - num_collision_prim_bytes); - - prim_aabb_ptr = (AABB *)cu::allocGPU( - num_collision_aabb_bytes); - - obj_aabb_ptr = (AABB *)cu::allocGPU(num_obj_aabb_bytes); - - offsets_ptr = (uint32_t *)cu::allocGPU(num_offset_bytes); - counts_ptr = (uint32_t *)cu::allocGPU(num_count_bytes); - - metadata_ptr = - (RigidBodyMetadata *)cu::allocGPU(num_metadata_bytes); - - mgr = (ObjectManager *)cu::allocGPU(sizeof(ObjectManager)); - - ObjectManager local { - primitives_ptr, - prim_aabb_ptr, - obj_aabb_ptr, - offsets_ptr, - counts_ptr, - metadata_ptr, - }; - - REQ_CUDA(cudaMemcpy(mgr, &local, sizeof(ObjectManager), - cudaMemcpyHostToDevice)); -#endif - } break; - default: MADRONA_UNREACHABLE(); - } - - return new Impl { - .primitives = primitives_ptr, - .primAABBs = prim_aabb_ptr, - .objAABBs = obj_aabb_ptr, - .rigidBodyPrimitiveOffsets = offsets_ptr, - .rigidBodyPrimitiveCounts = counts_ptr, - .metadatas = metadata_ptr, - .curPrimOffset = 0, - .curObjOffset = 0, - .mgr = mgr, - .maxPrims = max_objects * max_prims_per_object, - .maxObjs = max_objects, - .execMode = exec_mode, - }; - } -}; - -PhysicsLoader::PhysicsLoader(ExecMode exec_mode, CountT max_objects) - : impl_(Impl::init(exec_mode, max_objects)) -{} - -PhysicsLoader::~PhysicsLoader() -{ - if (impl_ == nullptr) { - return; - } - - switch (impl_->execMode) { - case ExecMode::CPU: { - delete impl_->mgr; - free(impl_->primitives); - free(impl_->primAABBs); - free(impl_->objAABBs); - free(impl_->rigidBodyPrimitiveOffsets); - free(impl_->rigidBodyPrimitiveCounts); - free(impl_->metadatas); - } break; - case ExecMode::CUDA: { -#ifndef MADRONA_CUDA_SUPPORT - noCUDA(); -#else - cu::deallocGPU(impl_->primitives); - cu::deallocGPU(impl_->primAABBs); - cu::deallocGPU(impl_->objAABBs); - cu::deallocGPU(impl_->rigidBodyPrimitiveOffsets); - cu::deallocGPU(impl_->rigidBodyPrimitiveCounts); - cu::deallocGPU(impl_->metadatas); -#endif - } break; - } -} - -PhysicsLoader::PhysicsLoader(PhysicsLoader &&o) = default; - -CountT PhysicsLoader::loadRigidBodies(const RigidBodyAssets &assets) -{ - CountT cur_obj_offset = impl_->curObjOffset; - impl_->curObjOffset += assets.numObjs; - CountT cur_prim_offset = impl_->curPrimOffset; - impl_->curPrimOffset += assets.totalNumPrimitives; - assert(impl_->curObjOffset <= impl_->maxObjs); - assert(impl_->curPrimOffset <= impl_->maxPrims); - - CollisionPrimitive *prims_dst = &impl_->primitives[cur_prim_offset]; - AABB *prim_aabbs_dst = &impl_->primAABBs[cur_prim_offset]; - - AABB *obj_aabbs_dst = &impl_->objAABBs[cur_obj_offset]; - uint32_t *offsets_dst = &impl_->rigidBodyPrimitiveOffsets[cur_obj_offset]; - uint32_t *counts_dst = &impl_->rigidBodyPrimitiveCounts[cur_obj_offset]; - RigidBodyMetadata *metadatas_dst = &impl_->metadatas[cur_obj_offset]; - - // FIXME: redo all this, leaks memory, slow, etc. Very non optimal on the - // CPU. - - uint32_t *offsets_tmp = (uint32_t *)malloc( - sizeof(uint32_t) * assets.numObjs); - for (CountT i = 0; i < (CountT)assets.numObjs; i++) { - offsets_tmp[i] = assets.primOffsets[i] + cur_prim_offset; - } - - HalfEdge *hull_halfedges; - uint32_t *hull_face_base_halfedges; - Plane *hull_face_planes; - Vector3 *hull_verts; - switch (impl_->execMode) { - case ExecMode::CPU: { - memcpy(prim_aabbs_dst, assets.primitiveAABBs, - sizeof(AABB) * assets.totalNumPrimitives); - - memcpy(obj_aabbs_dst, assets.objAABBs, - sizeof(AABB) * assets.numObjs); - memcpy(offsets_dst, offsets_tmp, - sizeof(uint32_t) * assets.numObjs); - memcpy(counts_dst, assets.primCounts, - sizeof(uint32_t) * assets.numObjs); - memcpy(metadatas_dst, assets.metadatas, - sizeof(RigidBodyMetadata) * assets.numObjs); - - hull_halfedges = (HalfEdge *)malloc( - sizeof(HalfEdge) * assets.hullData.numHalfEdges); - hull_face_base_halfedges = (uint32_t *)malloc( - sizeof(uint32_t) * assets.hullData.numFaces); - hull_face_planes = (Plane *)malloc( - sizeof(Plane) * assets.hullData.numFaces); - hull_verts = (Vector3 *)malloc( - sizeof(Vector3) * assets.hullData.numVerts); - - memcpy(hull_halfedges, assets.hullData.halfEdges, - sizeof(HalfEdge) * assets.hullData.numHalfEdges); - memcpy(hull_face_base_halfedges, assets.hullData.faceBaseHalfEdges, - sizeof(uint32_t) * assets.hullData.numFaces); - memcpy(hull_face_planes, assets.hullData.facePlanes, - sizeof(Plane) * assets.hullData.numFaces); - memcpy(hull_verts, assets.hullData.vertices, - sizeof(Vector3) * assets.hullData.numVerts); - } break; - case ExecMode::CUDA: { -#ifndef MADRONA_CUDA_SUPPORT - noCUDA(); -#else - cudaMemcpy(prim_aabbs_dst, assets.primitiveAABBs, - sizeof(AABB) * assets.totalNumPrimitives, - cudaMemcpyHostToDevice); - - cudaMemcpy(obj_aabbs_dst, assets.objAABBs, - sizeof(AABB) * assets.numObjs, - cudaMemcpyHostToDevice); - cudaMemcpy(offsets_dst, offsets_tmp, - sizeof(uint32_t) * assets.numObjs, - cudaMemcpyHostToDevice); - cudaMemcpy(counts_dst, assets.primCounts, - sizeof(uint32_t) * assets.numObjs, - cudaMemcpyHostToDevice); - cudaMemcpy(metadatas_dst, assets.metadatas, - sizeof(RigidBodyMetadata) * assets.numObjs, - cudaMemcpyHostToDevice); - - hull_halfedges = (HalfEdge *)cu::allocGPU( - sizeof(HalfEdge) * assets.hullData.numHalfEdges); - hull_face_base_halfedges = (uint32_t *)cu::allocGPU( - sizeof(uint32_t) * assets.hullData.numFaces); - hull_face_planes = (Plane *)cu::allocGPU( - sizeof(Plane) * assets.hullData.numFaces); - hull_verts = (Vector3 *)cu::allocGPU( - sizeof(Vector3) * assets.hullData.numVerts); - - cudaMemcpy(hull_halfedges, assets.hullData.halfEdges, - sizeof(HalfEdge) * assets.hullData.numHalfEdges, - cudaMemcpyHostToDevice); - cudaMemcpy(hull_face_base_halfedges, assets.hullData.faceBaseHalfEdges, - sizeof(uint32_t) * assets.hullData.numFaces, - cudaMemcpyHostToDevice); - cudaMemcpy(hull_face_planes, assets.hullData.facePlanes, - sizeof(Plane) * assets.hullData.numFaces, - cudaMemcpyHostToDevice); - cudaMemcpy(hull_verts, assets.hullData.vertices, - sizeof(Vector3) * assets.hullData.numVerts, - cudaMemcpyHostToDevice); -#endif - } - } - - auto primitives_tmp = (CollisionPrimitive *)malloc( - sizeof(CollisionPrimitive) * assets.totalNumPrimitives); - memcpy(primitives_tmp, assets.primitives, - sizeof(CollisionPrimitive) * assets.totalNumPrimitives); - - for (CountT i = 0; i < (CountT)assets.totalNumPrimitives; i++) { - CollisionPrimitive &cur_primitive = primitives_tmp[i]; - if (cur_primitive.type != CollisionPrimitive::Type::Hull) continue; - - HalfEdgeMesh &he_mesh = cur_primitive.hull.halfEdgeMesh; - - // FIXME: incoming HalfEdgeMeshes should have offsets or something - CountT hedge_offset = he_mesh.halfEdges - assets.hullData.halfEdges; - CountT face_offset = - he_mesh.facePlanes - assets.hullData.facePlanes; - CountT vert_offset = he_mesh.vertices - assets.hullData.vertices; - - he_mesh.halfEdges = hull_halfedges + hedge_offset; - he_mesh.faceBaseHalfEdges = hull_face_base_halfedges + face_offset; - he_mesh.facePlanes = hull_face_planes + face_offset; - he_mesh.vertices = hull_verts + vert_offset; - } - - switch (impl_->execMode) { - case ExecMode::CPU: { - memcpy(prims_dst, primitives_tmp, - sizeof(CollisionPrimitive) * assets.totalNumPrimitives); - } break; - case ExecMode::CUDA: { -#ifdef MADRONA_CUDA_SUPPORT - cudaMemcpy(prims_dst, primitives_tmp, - sizeof(CollisionPrimitive) * assets.totalNumPrimitives, - cudaMemcpyHostToDevice); -#endif - } break; - } - - free(primitives_tmp); - free(offsets_tmp); - - return cur_obj_offset; -} - -ObjectManager & PhysicsLoader::getObjectManager() -{ - return *impl_->mgr; -} - -} diff --git a/src/physics/tgs.cpp b/src/physics/tgs.cpp deleted file mode 100644 index 0218709a..00000000 --- a/src/physics/tgs.cpp +++ /dev/null @@ -1,304 +0,0 @@ -#include - -#include "physics_impl.hpp" -#include "tgs.hpp" - -// This implementation is inspired by the TGS-Soft solver from Solver2D: -// https://github.com/erincatto/solver2d/src/solve_tgs_soft.c -// Solver2D is MIT licensed, Copyright 2024 Erin Catto - -namespace madrona::phys::tgs { - -struct Contact : Archetype {}; -struct Joint : Archetype {}; - -// Any per-body solver state would go in components in this bundle -// (check XPBDRigidBodyState for example). -struct TGSRigidBodyState : Bundle< -> {}; - -struct SolverState { - Query jointQuery; - Query contactQuery; -}; - -using namespace base; -using namespace math; - -void registerTypes(ECSRegistry ®istry) -{ - registry.registerArchetype(); - registry.registerArchetype(); - - // Any components in the bundle specific to this solver must be - // registered first. - registry.registerBundle(); - - // This registers the solver's per-body state bundle for use in all - // rigid bodies in the system - registry.registerBundleAlias(); - - registry.registerSingleton(); -} - -void init(Context &ctx) -{ - new (&ctx.singleton()) SolverState { - .jointQuery = ctx.query(), - .contactQuery = ctx.query(), - }; -} - -void getSolverArchetypeIDs(uint32_t *contact_archetype_id, - uint32_t *joint_archetype_id) -{ - *contact_archetype_id = TypeTracker::typeID(); - *joint_archetype_id = TypeTracker::typeID(); -} - -static inline void solveJoints(Context &ctx, - SolverState &solver, - bool use_bias) -{ - (void)ctx; - (void)solver; - (void)use_bias; -} - -static inline void solveContacts(Context &ctx, - SolverState &solver, - bool use_bias) -{ - ctx.iterateQuery(solver.contactQuery, [&](ContactConstraint &contact) { - // Solve contact - (void)contact; - (void)use_bias; - }); -} - -inline void prepareContacts(Context &ctx, - ContactConstraint constraint) -{ - (void)ctx; - (void)constraint; -} - -inline void prepareJoints(Context &ctx, - JointConstraint constraint) -{ - (void)ctx; - (void)constraint; -} - -inline void integrateVelocities(Context &ctx, - Rotation q, - ResponseType response_type, - ExternalForce ext_force, - ExternalTorque ext_torque, - ObjectID obj_id, - Velocity &vel) -{ - if (response_type == ResponseType::Static) { - return; - } - - Vector3 v = vel.linear; - Vector3 omega = vel.angular; - - const auto &physics_sys = ctx.singleton(); - const ObjectManager &obj_mgr = *ctx.singleton().mgr; - const RigidBodyMetadata &metadata = obj_mgr.metadata[obj_id.idx]; - - float inv_m = metadata.mass.invMass; - Diag3x3 inv_I = Diag3x3::fromVec(metadata.mass.invInertiaTensor); - - float h = physics_sys.h; - - if (response_type == ResponseType::Dynamic) { - v += h * physics_sys.g; - } - - v += h * inv_m * ext_force; - - Diag3x3 I = { - (inv_I.d0 == 0) ? 0.0f : 1.0f / inv_I.d0, - (inv_I.d1 == 0) ? 0.0f : 1.0f / inv_I.d1, - (inv_I.d2 == 0) ? 0.0f : 1.0f / inv_I.d2, - }; - - Quat to_local = q.inv(); - - Vector3 tau_ext_local = to_local.rotateVec(ext_torque); - Vector3 omega_local = to_local.rotateVec(omega); - - // Integrate omega in local space - omega_local += - h * inv_I * (tau_ext_local - (cross(omega_local, I * omega))); - - omega = q.rotateVec(omega_local); - - // FIXME damping - - vel.linear = v; - vel.angular = omega; -} - -inline void warmStartContacts(Context &ctx, - ContactConstraint constraint) -{ - (void)ctx; - (void)constraint; -} - -inline void warmStartJoints(Context &ctx, - JointConstraint constraint) -{ - (void)ctx; - (void)constraint; -} - -inline void solveJointsBiased(Context &ctx, - SolverState &solver) -{ - solveJoints(ctx, solver, true); -} - -inline void solveContactsBiased(Context &ctx, - SolverState &solver) -{ - solveContacts(ctx, solver, true); -} - -inline void integratePositions(Context &ctx, - Position &pos, - Rotation &rot, - Velocity vel) -{ - Vector3 x = pos; - Quat q = rot; - - Vector3 v = vel.linear; - Vector3 omega = vel.angular; - - const auto &physics_sys = ctx.singleton(); - float h = physics_sys.h; - - x += h * v; - - Quat apply_omega = Quat::fromAngularVec(0.5f * h * omega); - - q += apply_omega * q; - q = q.normalize(); - - pos = x; - rot = q; -} - -inline void solveJointsUnbiased(Context &ctx, - SolverState &solver) -{ - solveJoints(ctx, solver, false); -} - -inline void solveContactsUnbiased(Context &ctx, - SolverState &solver) -{ - solveContacts(ctx, solver, false); -} - -TaskGraphNodeID setupTGSSolverTasks( - TaskGraphBuilder &builder, - TaskGraphNodeID broadphase, - CountT num_substeps) -{ - auto run_narrowphase = narrowphase::setupTasks(builder, {broadphase}); - auto clear_broadphase = builder.addToGraph< - ClearTmpNode>({run_narrowphase}); - - auto constraints_ready = clear_broadphase; -#ifdef MADRONA_GPU_MODE - // The GPU backend requires constraints to be sorted before iterateQuery - // can be called. This requires sorting both Contact and Joint entities. - constraints_ready = builder.addToGraph>( - {constraints_ready}); - - constraints_ready = - builder.addToGraph({constraints_ready}); - - constraints_ready = builder.addToGraph>( - {constraints_ready}); - - constraints_ready = - builder.addToGraph({constraints_ready}); -#endif - - auto cur_node = constraints_ready; - - cur_node = builder.addToGraph>({cur_node}); - - cur_node = builder.addToGraph>({cur_node}); - - for (CountT i = 0; i < num_substeps; i++) { - cur_node = builder.addToGraph>({cur_node}); - - cur_node = builder.addToGraph>({cur_node}); - - cur_node = builder.addToGraph>({cur_node}); - - cur_node = builder.addToGraph>({cur_node}); - - cur_node = builder.addToGraph>({cur_node}); - - cur_node = builder.addToGraph>({cur_node}); - - cur_node = builder.addToGraph>({cur_node}); - - cur_node = builder.addToGraph>({cur_node}); - } - - // For now, we don't have any persistent contacts support, so clear - // the contacts (free the memory). - auto clear_contacts = builder.addToGraph< - ClearTmpNode>({cur_node}); - - return clear_contacts; -} - -} diff --git a/src/physics/tgs.hpp b/src/physics/tgs.hpp deleted file mode 100644 index ce86f6c0..00000000 --- a/src/physics/tgs.hpp +++ /dev/null @@ -1,19 +0,0 @@ -#pragma once - -#include "physics_impl.hpp" - -namespace madrona::phys::tgs { - -void registerTypes(ECSRegistry ®istry); - -void getSolverArchetypeIDs(uint32_t *contact_archetype_id, - uint32_t *joint_archetype_id); - -void init(Context &ctx); - -TaskGraphNodeID setupTGSSolverTasks( - TaskGraphBuilder &builder, - TaskGraphNodeID broadphase, - CountT num_substeps); - -} diff --git a/src/physics/xpbd.cpp b/src/physics/xpbd.cpp deleted file mode 100644 index 393129cc..00000000 --- a/src/physics/xpbd.cpp +++ /dev/null @@ -1,1146 +0,0 @@ -#include -#include - -#include "physics_impl.hpp" -#include "xpbd.hpp" - -namespace madrona::phys::xpbd { - -struct XPBDContactState { - float lambdaN[4]; -}; - -struct Contact : Archetype< - ContactConstraint, - XPBDContactState -> {}; - -struct Joint : Archetype {}; - -struct SolverState { - Query jointQuery; - Query contactQuery; -}; - -struct SubstepPrevState { - math::Vector3 prevPosition; - math::Quat prevRotation; -}; - -struct PreSolvePositional { - math::Vector3 x; - math::Quat q; -}; - -struct PreSolveVelocity { - math::Vector3 v; - math::Vector3 omega; -}; - -struct XPBDRigidBodyState : Bundle< - SubstepPrevState, - PreSolvePositional, - PreSolveVelocity -> {}; - -namespace XPBDCols { - constexpr inline CountT SubstepPrevState = RGDCols::SolverBase; - constexpr inline CountT PreSolvePositional = RGDCols::SolverBase + 1; - constexpr inline CountT PreSolveVelocity = RGDCols::SolverBase + 2; -}; - -using namespace base; -using namespace math; - -static inline bool hasNaN(Vector3 v) -{ - return isnan(v.x) || isnan(v.y) || isnan(v.z); -} - -static inline bool hasNaN(Quat q) -{ - return isnan(q.w) || isnan(q.x) || isnan(q.y) || isnan(q.z); -} - -static inline Vector3 multDiag(Vector3 diag, Vector3 v) -{ - return Vector3 { - diag.x * v.x, - diag.y * v.y, - diag.z * v.z, - }; -} - -[[maybe_unused]] static inline Vector3 computeEnergy( - float inv_m, Vector3 inv_I, Vector3 v, Vector3 omega, Quat q) -{ - if (inv_m == 0.f || inv_I.x == 0.f || inv_I.y == 0.f || inv_I.z == 0.f) { - return {0.f, 0.f, 0.f}; - } - - float m = 1.f / inv_m; - Vector3 I { - 1.f / inv_I.x, - 1.f / inv_I.y, - 1.f / inv_I.z, - }; - - float linear = m * v.length2(); - - Vector3 omega_local = q.inv().rotateVec(omega); - float rotational = dot(omega_local, multDiag(I, omega_local)); - - return { - 0.5f * (linear + rotational), - 0.5f * linear, - 0.5f * rotational, - }; -} - -inline void substepRigidBodies(Context &ctx, - Position &pos, - Rotation &rot, - const Velocity &vel, - const ObjectID &obj_id, - ResponseType response_type, - ExternalForce &ext_force, - ExternalTorque &ext_torque, - SubstepPrevState &prev_state, - PreSolvePositional &presolve_pos, - PreSolveVelocity &presolve_vel) -{ - Vector3 x = pos; - Quat q = rot; - - Vector3 v = vel.linear; - Vector3 omega = vel.angular; - - if (response_type == ResponseType::Static) { - // FIXME: currently presolve_pos and prev_state need to be set every - // frame even for static objects. A better solution would be on - // creation / making a non-static object static, these variables are - // set once. This would require a dedicated API to control this rather - // than just setting objects to ResponseType::Static - prev_state.prevPosition = x; - prev_state.prevRotation = q; - - presolve_pos.x = x; - presolve_pos.q = q; - presolve_vel.v = Vector3::zero(); - presolve_vel.omega = Vector3::zero(); - - return; - } - - prev_state.prevPosition = x; - prev_state.prevRotation = q; - - const auto &physics_sys = ctx.singleton(); - const ObjectManager &obj_mgr = *ctx.singleton().mgr; - const RigidBodyMetadata &metadata = obj_mgr.metadata[obj_id.idx]; - - float inv_m = metadata.mass.invMass; - Vector3 inv_I = metadata.mass.invInertiaTensor; - - float h = physics_sys.h; - - if (response_type == ResponseType::Dynamic) { - v += h * physics_sys.g; - } - - v += h * inv_m * ext_force; - - x += h * v; - - Vector3 I = { - (inv_I.x == 0) ? 0.0f : 1.0f / inv_I.x, - (inv_I.y == 0) ? 0.0f : 1.0f / inv_I.y, - (inv_I.z == 0) ? 0.0f : 1.0f / inv_I.z - }; - - Quat to_local = q.inv(); - - Vector3 tau_ext_local = to_local.rotateVec(ext_torque); - Vector3 omega_local = to_local.rotateVec(omega); - - Vector3 I_omega_local = multDiag(I, omega_local); - - omega_local += h * multDiag(inv_I, - tau_ext_local - (cross(omega_local, I_omega_local))); - - omega = q.rotateVec(omega_local); - - Quat apply_omega = Quat::fromAngularVec(0.5f * h * omega); - - q += apply_omega * q; - q = q.normalize(); - - pos = x; - rot = q; - - presolve_pos.x = x; - presolve_pos.q = q; - presolve_vel.v = v; - presolve_vel.omega = omega; -} - -[[maybe_unused]] inline void checkSubstep(Context &, - Entity, - const Position &pos, - const Rotation &rot, - const Velocity &vel, - const ObjectID &, - const ResponseType &, - const ExternalForce &, - const ExternalTorque &, - const SubstepPrevState &, - const PreSolvePositional &, - const PreSolveVelocity &) -{ - assert(!hasNaN(pos)); - assert(!hasNaN(rot)); - assert(!hasNaN(vel.linear)); - assert(!hasNaN(vel.angular)); -} - -static inline float generalizedInverseMass(Vector3 torque_axis, - Vector3 rot_axis, - float inv_m) -{ - return inv_m + dot(torque_axis, rot_axis); -} - -static float computePositionalLambda( - Vector3 torque_axis1, Vector3 torque_axis2, - Vector3 rot_axis1, Vector3 rot_axis2, - float inv_m1, float inv_m2, - float c, float alpha_tilde) -{ - float w1 = generalizedInverseMass(torque_axis1, rot_axis1, inv_m1); - float w2 = generalizedInverseMass(torque_axis2, rot_axis2, inv_m2); - - return -c / (w1 + w2 + alpha_tilde); -} - -MADRONA_ALWAYS_INLINE static inline void applyPositionalUpdate( - Vector3 &x1, Vector3 &x2, - Quat &q1, Quat &q2, - Vector3 rot_axis_local1, Vector3 rot_axis_local2, - float inv_m1, float inv_m2, - Vector3 n, - float delta_lambda) -{ - x1 += delta_lambda * inv_m1 * n; - x2 -= delta_lambda * inv_m2 * n; - - float half_lambda = 0.5f * delta_lambda; - - Vector3 q1_update_angular_local = half_lambda * rot_axis_local1; - Vector3 q1_update_angular = q1.rotateVec(q1_update_angular_local); - - Vector3 q2_update_angular_local = half_lambda * rot_axis_local2; - Vector3 q2_update_angular = q2.rotateVec(q2_update_angular_local); - - q1 += Quat::fromAngularVec(q1_update_angular) * q1; - q2 -= Quat::fromAngularVec(q2_update_angular) * q2; - - // Paper doesn't explicitly call for normalization but we immediately - // use q1 and q2 for the next constraint - q1 = q1.normalize(); - q2 = q2.normalize(); -} - -MADRONA_ALWAYS_INLINE static inline float applyPositionalUpdate( - Vector3 &x1, Vector3 &x2, - Quat &q1, Quat &q2, - Vector3 r1, Vector3 r2, - float inv_m1, float inv_m2, - Vector3 inv_I1, Vector3 inv_I2, - Vector3 n_world, - float c, float alpha_tilde) -{ - Vector3 n_local1 = q1.inv().rotateVec(n_world); - Vector3 n_local2 = q2.inv().rotateVec(n_world); - - Vector3 torque_axis_local1 = cross(r1, n_local1); - Vector3 torque_axis_local2 = cross(r2, n_local2); - - Vector3 rot_axis_local1 = multDiag(inv_I1, torque_axis_local1); - Vector3 rot_axis_local2 = multDiag(inv_I2, torque_axis_local2); - - float lambda = computePositionalLambda( - torque_axis_local1, torque_axis_local2, - rot_axis_local1, rot_axis_local2, - inv_m1, inv_m2, - c, alpha_tilde); - - applyPositionalUpdate( - x1, x2, - q1, q2, - rot_axis_local1, rot_axis_local2, - inv_m1, inv_m2, - n_world, lambda); - - return lambda; -} - -MADRONA_ALWAYS_INLINE static inline -std::pair computeAngularUpdate( - Quat q1, Quat q2, - Vector3 inv_I1, Vector3 inv_I2, - Vector3 n1, Vector3 n2, - float theta, - float alpha_tilde) -{ - Vector3 local_rot_axis1 = multDiag(inv_I1, n1); - Vector3 local_rot_axis2 = multDiag(inv_I2, n2); - - float w1 = dot(n1, local_rot_axis1); - float w2 = dot(n2, local_rot_axis2); - - float delta_lambda = -theta / (w1 + w2 + alpha_tilde); - - float half_lambda = 0.5f * delta_lambda; - Vector3 q1_update_angular_local = half_lambda * local_rot_axis1; - Vector3 q2_update_angular_local = half_lambda * local_rot_axis2; - - return { - Quat::fromAngularVec(q1.rotateVec(q1_update_angular_local)), - Quat::fromAngularVec(q2.rotateVec(q2_update_angular_local)), - }; -} - -static void applyAngularUpdate( - Quat &q1, Quat &q2, - Quat q1_update, Quat q2_update) -{ - q1 = (q1 + q1_update * q1).normalize(); - q2 = (q2 - q2_update * q2).normalize(); -} - -MADRONA_ALWAYS_INLINE static inline void handleContactConstraint( - Vector3 &x1, Vector3 &x2, - Quat &q1, Quat &q2, - SubstepPrevState prev1, SubstepPrevState prev2, - float inv_m1, float inv_m2, - Vector3 inv_I1, Vector3 inv_I2, - Vector3 r1, Vector3 r2, - Vector3 n_world, - float avg_mu_s, - float *lambda_n_out, - float *lambda_t_out) -{ - Vector3 p1 = q1.rotateVec(r1) + x1; - Vector3 p2 = q2.rotateVec(r2) + x2; - - float d = dot(p1 - p2, n_world); - - if (d <= 0) { - return; - } - - float lambda_n = applyPositionalUpdate( - x1, x2, - q1, q2, - r1, r2, - inv_m1, inv_m2, - inv_I1, inv_I2, - n_world, - d, 0); - *lambda_n_out = lambda_n; - - Vector3 x1_prev = prev1.prevPosition; - Quat q1_prev = prev1.prevRotation; - - Vector3 x2_prev = prev2.prevPosition; - Quat q2_prev = prev2.prevRotation; - - Vector3 p1_hat = q1_prev.rotateVec(r1) + x1_prev; - Vector3 p2_hat = q2_prev.rotateVec(r2) + x2_prev; - - // Update p1 and p2 so static friction covers any drift - // as a result of the positional correction along the normal - p1 = q1.rotateVec(r1) + x1; - p2 = q2.rotateVec(r2) + x2; - - Vector3 delta_p = (p1 - p1_hat) - (p2 - p2_hat); - Vector3 delta_p_t = delta_p - dot(delta_p, n_world) * n_world; - - float tangential_magnitude = delta_p_t.length(); - - if (tangential_magnitude > 0.f) { - Vector3 t_world = delta_p_t / tangential_magnitude; - Vector3 t_local1 = q1.inv().rotateVec(t_world); - Vector3 t_local2 = q2.inv().rotateVec(t_world); - - Vector3 friction_torque_axis_local1 = cross(r1, t_local1); - Vector3 friction_torque_axis_local2 = cross(r2, t_local2); - - Vector3 friction_rot_axis_local1 = - multDiag(inv_I1, friction_torque_axis_local1); - - Vector3 friction_rot_axis_local2 = - multDiag(inv_I2, friction_torque_axis_local2); - - float lambda_t = computePositionalLambda( - friction_torque_axis_local1, friction_torque_axis_local2, - friction_rot_axis_local1, friction_rot_axis_local2, - inv_m1, inv_m2, - tangential_magnitude, 0); - float lambda_threshold = lambda_n * avg_mu_s; - - if (lambda_t > lambda_threshold) { - *lambda_t_out = lambda_t; - - applyPositionalUpdate( - x1, x2, - q1, q2, - friction_rot_axis_local1, friction_rot_axis_local2, - inv_m1, inv_m2, - t_world, lambda_t); - } - } -} - -MADRONA_ALWAYS_INLINE static inline std::pair -getLocalSpaceContacts(const PreSolvePositional &presolve_pos1, - const PreSolvePositional &presolve_pos2, - Vector3 contact1, float penetration_depth, - Vector3 contact_normal) -{ - Vector3 contact2 = - contact1 - contact_normal * penetration_depth; - - // Transform the contact points into local space for a & b - Vector3 r1 = presolve_pos1.q.inv().rotateVec(contact1 - presolve_pos1.x); - Vector3 r2 = presolve_pos2.q.inv().rotateVec(contact2 - presolve_pos2.x); - - return { r1, r2 }; -} - -static bool getAvgContact(ContactConstraint contact, Vector3 *avg_out, float *penetration_out) -{ - Vector3 avg_contact = Vector3::zero(); - - float max_penetration = -FLT_MAX; - float penetration_sum = 0.f; - for (CountT i = 0; i < contact.numPoints; i++) { - Vector4 pt = contact.points[i]; - if (pt.w > max_penetration) { - max_penetration = pt.w; - } - - penetration_sum += pt.w; - } - - if (penetration_sum == 0.f) { - return true; - } - - for (CountT i = 0; i < contact.numPoints; i++) { - Vector4 pt = contact.points[i]; - avg_contact += pt.w / penetration_sum * pt.xyz(); - } - - *avg_out = avg_contact; - *penetration_out = max_penetration; - - return false; -} - -// For now, this function assumes both a & b are dynamic objects. -// FIXME: Need to add dynamic / static variant or handle missing the velocity -// component for static objects. -static inline void handleContact(Context &ctx, - ObjectManager &obj_mgr, - ContactConstraint contact, - float *lambdas) -{ - Position *x1_ptr = &ctx.getDirect(RGDCols::Position, contact.ref); - Position *x2_ptr = &ctx.getDirect(RGDCols::Position, contact.alt); - - Rotation *q1_ptr = &ctx.getDirect(RGDCols::Rotation, contact.ref); - Rotation *q2_ptr = &ctx.getDirect(RGDCols::Rotation, contact.alt); - - SubstepPrevState prev1 = ctx.getDirect( - XPBDCols::SubstepPrevState, contact.ref); - SubstepPrevState prev2 = ctx.getDirect( - XPBDCols::SubstepPrevState, contact.alt); - - PreSolvePositional presolve_pos1 = ctx.getDirect( - XPBDCols::PreSolvePositional, contact.ref); - PreSolvePositional presolve_pos2 = ctx.getDirect( - XPBDCols::PreSolvePositional, contact.alt); - - ObjectID obj_id1 = ctx.getDirect( - RGDCols::ObjectID, contact.ref); - ObjectID obj_id2 = ctx.getDirect(RGDCols::ObjectID, contact.alt); - - ResponseType resp_type1 = ctx.getDirect( - RGDCols::ResponseType, contact.ref); - ResponseType resp_type2 = ctx.getDirect( - RGDCols::ResponseType, contact.alt); - - RigidBodyMetadata metadata1 = obj_mgr.metadata[obj_id1.idx]; - RigidBodyMetadata metadata2 = obj_mgr.metadata[obj_id2.idx]; - - Vector3 x1 = *x1_ptr; - Vector3 x2 = *x2_ptr; - - Quat q1 = *q1_ptr; - Quat q2 = *q2_ptr; - - float inv_m1 = metadata1.mass.invMass; - float inv_m2 = metadata2.mass.invMass; - - Vector3 inv_I1 = metadata1.mass.invInertiaTensor; - Vector3 inv_I2 = metadata2.mass.invInertiaTensor; - - if (resp_type1 == ResponseType::Static) { - inv_m1 = 0.f; - inv_I1 = Vector3::zero(); - } - - if (resp_type2 == ResponseType::Static) { - inv_m2 = 0.f; - inv_I2 = Vector3::zero(); - } - - float mu_s1 = metadata1.friction.muS; - float mu_s2 = metadata2.friction.muS; - - float avg_mu_s = 0.5f * (mu_s1 + mu_s2); - - Vector3 avg_contact_pos; - float contact_pos_penetration; - bool zero_separation = getAvgContact(contact, &avg_contact_pos, &contact_pos_penetration); - if (zero_separation) { - return; - } - - { - CountT i = 0; - - auto [r1, r2] = getLocalSpaceContacts( - presolve_pos1, presolve_pos2, - avg_contact_pos, contact_pos_penetration, contact.normal); - - float lambda_n = 0.f; - float lambda_t = 0.f; - - handleContactConstraint(x1, x2, - q1, q2, - prev1, prev2, - inv_m1, inv_m2, - inv_I1, inv_I2, - r1, r2, - contact.normal, - avg_mu_s, - &lambda_n, - &lambda_t); - - lambdas[i] = lambda_n; - } - - *x1_ptr = x1; - *x2_ptr = x2; - - *q1_ptr = q1; - *q2_ptr = q2; -} - -static void applyJointOrientationConstraint( - Quat &q1, Quat &q2, - Quat attach_q1, Quat attach_q2, - Vector3 inv_I1, Vector3 inv_I2) -{ - Quat orientation1 = (q1 * attach_q1).normalize(); - Quat orientation2 = (q2 * attach_q2).normalize(); - - Quat diff = orientation1 * orientation2.inv(); - - Vector3 delta_q = - 2.f * math::Vector3 { diff.x, diff.y, diff.z }; - float delta_q_magnitude = delta_q.length(); - - if (delta_q_magnitude > 0) { - delta_q /= delta_q_magnitude; - Vector3 delta_q_local1 = q1.inv().rotateVec(delta_q); - Vector3 delta_q_local2 = q2.inv().rotateVec(delta_q); - - auto [update_q1, update_q2] = computeAngularUpdate( - q1, q2, - inv_I1, inv_I2, - delta_q_local1, delta_q_local2, - delta_q_magnitude, 0); - - applyAngularUpdate(q1, q2, update_q1, update_q2); - } -} - -static void applyJointAxisConstraint( - Quat &q1, Quat &q2, - Vector3 axis1_local, Vector3 axis2_local, - Vector3 inv_I1, Vector3 inv_I2) -{ - Vector3 axis1 = q1.rotateVec(axis1_local); - Vector3 axis2 = q2.rotateVec(axis2_local); - - Vector3 delta_q = cross(axis1, axis2); - float delta_q_magnitude = delta_q.length(); - - if (delta_q_magnitude > 0) { - delta_q /= delta_q_magnitude; - Vector3 delta_q_local1 = q1.inv().rotateVec(delta_q); - Vector3 delta_q_local2 = q2.inv().rotateVec(delta_q); - - auto [update_q1, update_q2] = computeAngularUpdate( - q1, q2, - inv_I1, inv_I2, - delta_q_local1, delta_q_local2, - delta_q_magnitude, 0); - - applyAngularUpdate(q1, q2, update_q1, update_q2); - } -} - -inline void handleJointConstraint(Context &ctx, - JointConstraint joint) -{ - Loc l1 = ctx.loc(joint.e1); - Loc l2 = ctx.loc(joint.e2); - - Vector3 *x1_ptr = &ctx.getDirect(RGDCols::Position, l1); - Vector3 *x2_ptr = &ctx.getDirect(RGDCols::Position, l2); - Quat *q1_ptr = &ctx.getDirect(RGDCols::Rotation, l1); - Quat *q2_ptr = &ctx.getDirect(RGDCols::Rotation, l2); - Vector3 x1 = *x1_ptr; - Vector3 x2 = *x2_ptr; - Quat q1 = *q1_ptr; - Quat q2 = *q2_ptr; - ResponseType resp_type1 = ctx.getDirect( - RGDCols::ResponseType, l1); - ResponseType resp_type2 = ctx.getDirect( - RGDCols::ResponseType, l2); - ObjectID obj_id1 = ctx.getDirect(RGDCols::ObjectID, l1); - ObjectID obj_id2 = ctx.getDirect(RGDCols::ObjectID, l2); - - ObjectManager &obj_mgr = *ctx.singleton().mgr; - RigidBodyMetadata metadata1 = obj_mgr.metadata[obj_id1.idx]; - RigidBodyMetadata metadata2 = obj_mgr.metadata[obj_id2.idx]; - - float inv_m1 = metadata1.mass.invMass; - Vector3 inv_I1 = metadata1.mass.invInertiaTensor; - - if (resp_type1 == ResponseType::Static) { - inv_m1 = 0.f; - inv_I1 = Vector3::zero(); - } - - float inv_m2 = metadata2.mass.invMass; - Vector3 inv_I2 = metadata2.mass.invInertiaTensor; - - if (resp_type2 == ResponseType::Static) { - inv_m2 = 0.f; - inv_I2 = Vector3::zero(); - } - - Vector3 pos_correction; - switch (joint.type) { - case JointConstraint::Type::Fixed: { - JointConstraint::Fixed fixed_data = joint.fixed; - - applyJointOrientationConstraint( - q1, q2, - fixed_data.attachRot1, fixed_data.attachRot2, - inv_I1, inv_I2); - - Vector3 r1_world = q1.rotateVec(joint.r1) + x1; - Vector3 r2_world = q2.rotateVec(joint.r2) + x2; - - Vector3 delta_r = r2_world - r1_world; - - Quat axes_rot = (q1 * fixed_data.attachRot1).normalize(); - - Vector3 a1 = axes_rot.rotateVec(math::fwd); - Vector3 b1 = axes_rot.rotateVec(math::right); - Vector3 c1 = cross(a1, b1); - - // This implements a fixed distance qlong the a1 axis and no distance - // along the other axes - - pos_correction = Vector3::zero(); - { - // Unlike paper, subtract from pos_correction because - // applyPositionalUpdate applies the negative magnitude to object 1 - float a_separation = dot(delta_r, a1); - pos_correction -= (a_separation - fixed_data.separation) * a1; - - float b_separation = dot(delta_r, b1); - pos_correction -= b_separation * b1; - - float c_separation = dot(delta_r, c1); - pos_correction -= c_separation * c1; - } - } break; - case JointConstraint::Type::Hinge: { - JointConstraint::Hinge hinge_data = joint.hinge; - - applyJointAxisConstraint(q1, q2, - hinge_data.a1Local, hinge_data.a2Local, - inv_I1, inv_I2); - - Vector3 r1_world = q1.rotateVec(joint.r1) + x1; - Vector3 r2_world = q2.rotateVec(joint.r2) + x2; - - pos_correction = r2_world - r1_world; - } break; - default: MADRONA_UNREACHABLE(); - } - - float pos_correction_magnitude = pos_correction.length(); - if (pos_correction_magnitude > 0.f) { - pos_correction /= pos_correction_magnitude; - - applyPositionalUpdate( - x1, x2, - q1, q2, - joint.r1, joint.r2, - inv_m1, inv_m2, - inv_I1, inv_I2, - pos_correction, pos_correction_magnitude, 0); - } - - *x1_ptr = x1; - *x2_ptr = x2; - *q1_ptr = q1; - *q2_ptr = q2; -} - -inline void solvePositions(Context &ctx, SolverState &solver_state) -{ - ObjectManager &obj_mgr = *ctx.singleton().mgr; - - ctx.iterateQuery(solver_state.contactQuery, - [&](ContactConstraint &contact, XPBDContactState &contact_solver_state) { - contact_solver_state.lambdaN[0] = 0.f; - contact_solver_state.lambdaN[1] = 0.f; - contact_solver_state.lambdaN[2] = 0.f; - contact_solver_state.lambdaN[3] = 0.f; - handleContact(ctx, obj_mgr, contact, contact_solver_state.lambdaN); - }); - - ctx.iterateQuery(solver_state.jointQuery, [&](JointConstraint joint) { - handleJointConstraint(ctx, joint); - }); -} - -inline void setVelocities(Context &ctx, - const Position &pos, - const Rotation &rot, - const SubstepPrevState &prev_state, - Velocity &vel) -{ - const auto &physics_sys = ctx.singleton(); - float h = physics_sys.h; - - Vector3 x = pos; - Quat q = rot; - - Vector3 x_prev = prev_state.prevPosition; - Quat q_prev = prev_state.prevRotation; - - // when cur and prev rotation are equal there should be 0 angular velocity - // Unfortunately, this computation introduces a small amount of FP error - // and in some rotations the object winds up with a small delta_q, so - // we do a check and if all components match, just set delta_q to the - // identity quaternion manually - Quat delta_q; - if (q.w != q_prev.w || q.x != q_prev.x || - q.y != q_prev.y || q.z != q_prev.z) { - delta_q = q * q_prev.inv(); - } else { - delta_q = { 1, 0, 0, 0 }; - } - - // FIXME: A noticeable amount of energy is lost for bodies that should - // be under a constant angular velocity with no resistance. The issue - // seems to be that delta_q here is consistently slightly less than - // apply_omega in substepRigidBodies, so the angular velocity reduces - // a little bit each frame. Investigate whether this is an FP - // precision issue or a consequence of linearized angular -> quaternion - // formulas and see if it can be mitigated. Dividing delta_q by delta_q.w - // seems to fix the issue - does that have unintended consequences? - Vector3 new_omega = - 2.f / h * Vector3 { delta_q.x, delta_q.y, delta_q.z }; - - vel.linear = (x - x_prev) / h; - vel.angular = delta_q.w > 0.f ? new_omega : -new_omega; -} - -static inline Vector3 computeRelativeVelocity( - Vector3 v1, Vector3 v2, - Vector3 omega1, Vector3 omega2, - Vector3 dir1, Vector3 dir2) -{ - return (v1 + cross(omega1, dir1)) - (v2 + cross(omega2, dir2)); -} - -static inline void applyFrictionVelocityUpdate( - Vector3 &v1, Vector3 &v2, - Vector3 &omega1, Vector3 &omega2, - Quat q1, Quat q2, - float inv_m1, float inv_m2, - Vector3 inv_I1, Vector3 inv_I2, - Vector3 n, - float mu_d, float h, - Vector3 r1_local, Vector3 r2_local, - Vector3 r1_world, Vector3 r2_world, - float lambda) -{ - Vector3 v = computeRelativeVelocity( - v1, v2, omega1, omega2, r1_world, r2_world); - - float vn = dot(n, v); - Vector3 vt = v - n * vn; - - float vt_len = vt.length(); - if (vt_len == 0.f) { - return; - } - - Vector3 delta_world = vt / vt_len; - - Vector3 delta_local1 = q1.inv().rotateVec(delta_world); - Vector3 delta_local2 = q2.inv().rotateVec(delta_world); - - Vector3 friction_torque_axis_local1 = - cross(r1_local, delta_local1); - Vector3 friction_torque_axis_local2 = - cross(r2_local, delta_local2); - - Vector3 friction_rot_axis_local1 = multDiag( - inv_I1, friction_torque_axis_local1); - Vector3 friction_rot_axis_local2 = multDiag( - inv_I2, friction_torque_axis_local2); - - float w1 = generalizedInverseMass( - friction_torque_axis_local1, friction_rot_axis_local1, inv_m1); - float w2 = generalizedInverseMass( - friction_torque_axis_local2, friction_rot_axis_local2, inv_m2); - - float inv_mass_scale = 1.f / (w1 + w2); - - // h * mu_d * |f_n| in paper. Note the paper is incorrect here - // (doesn't have w1 + w2 divisor). - float dynamic_friction_magnitude = - mu_d * fabsf(lambda) * inv_mass_scale / h; - - float corrected_magnitude = - -fminf(dynamic_friction_magnitude, vt_len); - - float impulse_magnitude = corrected_magnitude * inv_mass_scale; - - if (impulse_magnitude == 0.f) { - return; - } - - v1 += delta_world * impulse_magnitude * inv_m1; - v2 -= delta_world * impulse_magnitude * inv_m2; - - Vector3 omega1_update_local = - impulse_magnitude * friction_rot_axis_local1; - Vector3 omega2_update_local = - impulse_magnitude * friction_rot_axis_local2; - - omega1 += q1.rotateVec(omega1_update_local); - omega2 -= q2.rotateVec(omega2_update_local); -} - -static inline void applyRestitutionVelocityUpdate( - Vector3 &v1, Vector3 &v2, - Vector3 &omega1, Vector3 &omega2, - Quat q1, Quat q2, - float inv_m1, float inv_m2, - Vector3 inv_I1, Vector3 inv_I2, - Vector3 n, - float restitution_threshold, - Vector3 r1_world, - Vector3 r2_world, - Vector3 restitution_torque_axis_local1, - Vector3 restitution_torque_axis_local2, - float vn_bar) -{ - Vector3 v = computeRelativeVelocity( - v1, v2, omega1, omega2, r1_world, r2_world); - - float vn = dot(n, v); - - float e = 0.3f; // FIXME - if (fabsf(vn_bar) <= restitution_threshold) { - e = 0.f; - } - - float restitution_magnitude = fminf(-e * vn_bar, 0) - vn; - - Vector3 restitution_rot_axis_local1 = - multDiag(inv_I1, restitution_torque_axis_local1); - Vector3 restitution_rot_axis_local2 = - multDiag(inv_I2, restitution_torque_axis_local2); - - float w1 = generalizedInverseMass( - restitution_torque_axis_local1, restitution_rot_axis_local1, inv_m1); - float w2 = generalizedInverseMass( - restitution_torque_axis_local2, restitution_rot_axis_local2, inv_m2); - - float inv_mass_scale = 1.f / (w1 + w2); - - float impulse_magnitude = restitution_magnitude * inv_mass_scale; - - if (impulse_magnitude == 0.f) { - return; - } - - v1 += n * impulse_magnitude * inv_m1; - v2 -= n * impulse_magnitude * inv_m2; - - Vector3 omega1_update_local = - impulse_magnitude * restitution_rot_axis_local1; - Vector3 omega2_update_local = - impulse_magnitude * restitution_rot_axis_local2; - - omega1 += q1.rotateVec(omega1_update_local); - omega2 -= q2.rotateVec(omega2_update_local); -} - -static inline void solveVelocitiesForContact(Context &ctx, - ObjectManager &obj_mgr, - ContactConstraint contact, - float lambdaN[4], - float h, - float restitution_threshold) -{ - Velocity *v1_out = &ctx.getDirect(RGDCols::Velocity, contact.ref); - Velocity *v2_out = &ctx.getDirect(RGDCols::Velocity, contact.alt); - - Quat q1 = ctx.getDirect(RGDCols::Rotation, contact.ref); - Quat q2 = ctx.getDirect(RGDCols::Rotation, contact.alt); - - PreSolvePositional presolve_pos1 = ctx.getDirect( - XPBDCols::PreSolvePositional, contact.ref); - PreSolvePositional presolve_pos2 = ctx.getDirect( - XPBDCols::PreSolvePositional, contact.alt); - - PreSolveVelocity presolve_vel1 = - ctx.getDirect(XPBDCols::PreSolveVelocity, contact.ref); - PreSolveVelocity presolve_vel2 = - ctx.getDirect(XPBDCols::PreSolveVelocity, contact.alt); - - ObjectID obj_id1 = ctx.getDirect(RGDCols::ObjectID, contact.ref); - ObjectID obj_id2 = ctx.getDirect(RGDCols::ObjectID, contact.alt); - - ResponseType resp_type1 = ctx.getDirect( - RGDCols::ResponseType, contact.ref); - ResponseType resp_type2 = ctx.getDirect( - RGDCols::ResponseType, contact.alt); - - RigidBodyMetadata metadata1 = obj_mgr.metadata[obj_id1.idx]; - RigidBodyMetadata metadata2 = obj_mgr.metadata[obj_id2.idx]; - - auto [v1, omega1] = *v1_out; - auto [v2, omega2] = *v2_out; - - float inv_m1 = metadata1.mass.invMass; - float inv_m2 = metadata2.mass.invMass; - Vector3 inv_I1 = metadata1.mass.invInertiaTensor; - Vector3 inv_I2 = metadata2.mass.invInertiaTensor; - - if (resp_type1 == ResponseType::Static) { - inv_m1 = 0.f; - inv_I1 = Vector3::zero(); - } - - if (resp_type2 == ResponseType::Static) { - inv_m2 = 0.f; - inv_I2 = Vector3::zero(); - } - - float mu_d = 0.5f * (metadata1.friction.muD + metadata2.friction.muD); - - { - Vector3 avg_contact_pos; - float contact_pos_penetration; - bool zero_separation = getAvgContact(contact, &avg_contact_pos, &contact_pos_penetration); - if (zero_separation) { - return; - } - - auto [r1, r2] = getLocalSpaceContacts(presolve_pos1, presolve_pos2, - avg_contact_pos, contact_pos_penetration, contact.normal); - - Vector3 r1_presolve = presolve_pos1.q.rotateVec(r1); - Vector3 r2_presolve = presolve_pos2.q.rotateVec(r2); - - Vector3 v_bar = computeRelativeVelocity( - presolve_vel1.v, presolve_vel2.v, - presolve_vel1.omega, presolve_vel2.omega, - r1_presolve, r2_presolve); // FIXME r1_world or presolve? - - float vn_bar = dot(contact.normal, v_bar); - - Vector3 r1_world = q1.rotateVec(r1); - Vector3 r2_world = q2.rotateVec(r2); - - Vector3 restitution_torque_axis_local1 = - cross(r1, q1.inv().rotateVec(contact.normal)); - Vector3 restitution_torque_axis_local2 = - cross(r2, q2.inv().rotateVec(contact.normal)); - - applyRestitutionVelocityUpdate( - v1, v2, - omega1, omega2, - q1, q2, - inv_m1, inv_m2, - inv_I1, inv_I2, - contact.normal, - restitution_threshold, - r1_world, r2_world, - restitution_torque_axis_local1, restitution_torque_axis_local2, - vn_bar); - } - - float penetration_sum = 0.f; - for (CountT i = 0; i < contact.numPoints; i++) { - penetration_sum += contact.points[i].w; - } - - for (CountT i = 0; i < contact.numPoints; i++) { - auto [r1, r2] = getLocalSpaceContacts(presolve_pos1, presolve_pos2, - contact.points[i].xyz(), contact.points[i].w, contact.normal); - - Vector3 r1_world = q1.rotateVec(r1); - Vector3 r2_world = q2.rotateVec(r2); - - applyFrictionVelocityUpdate( - v1, v2, - omega1, omega2, - q1, q2, - inv_m1, inv_m2, - inv_I1, inv_I2, - contact.normal, - mu_d, h, - r1, r2, - r1_world, r2_world, - lambdaN[0] * (contact.points[i].w / penetration_sum)); - } - - *v1_out = Velocity { v1, omega1 }; - *v2_out = Velocity { v2, omega2 }; -} - -inline void solveVelocities(Context &ctx, SolverState &solver) -{ - ObjectManager &obj_mgr = *ctx.singleton().mgr; - PhysicsSystemState &physics_sys = ctx.singleton(); - - ctx.iterateQuery(solver.contactQuery, - [&](ContactConstraint &contact, XPBDContactState &contact_solver_state) { - solveVelocitiesForContact( - ctx, obj_mgr, contact, contact_solver_state.lambdaN, - physics_sys.h, physics_sys.restitutionThreshold); - }); -} - -void registerTypes(ECSRegistry ®istry) -{ - registry.registerComponent(); - registry.registerComponent(); - registry.registerComponent(); - registry.registerComponent(); - - registry.registerArchetype(); - registry.registerArchetype(); - - registry.registerSingleton(); - - registry.registerBundle(); - registry.registerBundleAlias(); -} - -void init(Context &ctx) -{ - new (&ctx.singleton()) SolverState { - .jointQuery = ctx.query(), - .contactQuery = ctx.query(), - }; -} - -void getSolverArchetypeIDs(uint32_t *contact_archetype_id, - uint32_t *joint_archetype_id) -{ - *contact_archetype_id = TypeTracker::typeID(); - *joint_archetype_id = TypeTracker::typeID(); -} - -TaskGraphNodeID setupXPBDSolverTasks( - TaskGraphBuilder &builder, - TaskGraphNodeID broadphase, - CountT num_substeps) -{ - auto cur_node = broadphase; - -#ifdef MADRONA_GPU_MODE - cur_node = - builder.addToGraph>({cur_node}); - cur_node = builder.addToGraph({cur_node}); -#endif - - for (CountT i = 0; i < num_substeps; i++) { - auto rgb_update = builder.addToGraph>({cur_node}); - - auto run_narrowphase = narrowphase::setupTasks(builder, {rgb_update}); - -#ifdef MADRONA_GPU_MODE - run_narrowphase = builder.addToGraph>( - {run_narrowphase}); - - run_narrowphase = builder.addToGraph( - {run_narrowphase}); -#endif - - auto solve_pos = builder.addToGraph>( - {run_narrowphase}); - - auto vel_set = builder.addToGraph>({solve_pos}); - - auto solve_vel = builder.addToGraph>({vel_set}); - - auto clear_contacts = builder.addToGraph< - ClearTmpNode>({solve_vel}); - - cur_node = builder.addToGraph({clear_contacts}); - -#if 0 - cur_node = builder.addToGraph>({cur_node}); -#endif - } - - auto clear_broadphase = builder.addToGraph< - ClearTmpNode>({cur_node}); - - return clear_broadphase; -} - -} diff --git a/src/physics/xpbd.hpp b/src/physics/xpbd.hpp deleted file mode 100644 index 8d51f71b..00000000 --- a/src/physics/xpbd.hpp +++ /dev/null @@ -1,19 +0,0 @@ -#pragma once - -#include "physics_impl.hpp" - -namespace madrona::phys::xpbd { - -void registerTypes(ECSRegistry ®istry); - -void getSolverArchetypeIDs(uint32_t *contact_archetype_id, - uint32_t *joint_archetype_id); - -void init(Context &ctx); - -TaskGraphNodeID setupXPBDSolverTasks( - TaskGraphBuilder &builder, - TaskGraphNodeID broadphase, - CountT num_substeps); - -} From 7752a920dc1a74a44242cb0cad09f7b4257b6035 Mon Sep 17 00:00:00 2001 From: Alexis Duburcq Date: Thu, 23 Jul 2026 09:32:55 +0200 Subject: [PATCH 04/14] Remove the unused navmesh subsystem madrona_navmesh is never linked by any target and no ECS system references it; it was only dead weight in the JIT megakernel. Drop navmesh.cpp from the megakernel sources, delete the library, and remove the files. --- include/madrona/navmesh.hpp | 79 --------- include/madrona/navmesh.inl | 156 ------------------ src/common/CMakeLists.txt | 6 - src/common/navmesh.cpp | 318 ------------------------------------ src/mw/CMakeLists.txt | 1 - 5 files changed, 560 deletions(-) delete mode 100644 include/madrona/navmesh.hpp delete mode 100644 include/madrona/navmesh.inl delete mode 100644 src/common/navmesh.cpp diff --git a/include/madrona/navmesh.hpp b/include/madrona/navmesh.hpp deleted file mode 100644 index 93d4dac5..00000000 --- a/include/madrona/navmesh.hpp +++ /dev/null @@ -1,79 +0,0 @@ -#pragma once - -#include -#include -#include - -namespace madrona { - -struct Navmesh { - struct PathFindQueue { - float *costs; - uint32_t *heap; - uint32_t *heapIndex; - CountT heapSize; - - void add(uint32_t poly, float cost); - uint32_t removeMin(); - void decreaseCost(uint32_t poly, float cost); - }; - - struct AliasEntry { - float tau; - uint32_t alias; - }; - - math::Vector3 *vertices; - uint32_t *triIndices; - uint32_t *triAdjacency; - AliasEntry *triSampleAliasTable; - uint32_t numVerts; - uint32_t numTris; - - inline math::Vector3 samplePointAndPoly(RandKey rnd, uint32_t *out_poly); - inline math::Vector3 samplePoint(RandKey rnd); - - inline void getTriangleVertices(uint32_t tri_idx, - math::Vector3 *out_a, - math::Vector3 *out_b, - math::Vector3 *out_c); - - struct BFSState { - uint32_t *queue; - bool *visited; - }; - - template - inline void bfsFromPoly( - uint32_t poly, - BFSState bfs_state, - Fn &&fn); - - struct DijkstrasState { - float *distances; - math::Vector3 *entryPoints; - uint32_t *heap; - uint32_t *heapIndex; - }; - - template - inline void dijkstrasFromPoly( - uint32_t start_poly, - math::Vector3 start_pos, - DijkstrasState dijkstras_state, - Fn &&fn); - - static Navmesh initFromPolygons( - math::Vector3 *poly_vertices, - uint32_t *poly_idxs, - uint32_t *poly_idx_offsets, - uint32_t *poly_sizes, - uint32_t num_verts, - uint32_t num_polys); - - static constexpr inline uint32_t sentinel = 0xFFFF'FFFF; -}; - -} - -#include "navmesh.inl" diff --git a/include/madrona/navmesh.inl b/include/madrona/navmesh.inl deleted file mode 100644 index 4ee7eab8..00000000 --- a/include/madrona/navmesh.inl +++ /dev/null @@ -1,156 +0,0 @@ -#include - -namespace madrona { - -math::Vector3 Navmesh::samplePointAndPoly(RandKey rnd, uint32_t *out_poly) -{ - using namespace math; - - RandKey tbl_row_rnd = rand::split_i(rnd, 0); - RandKey alias_p_rnd = rand::split_i(rnd, 1); - RandKey bary_rnd = rand::split_i(rnd, 2); - - uint32_t tbl_row_idx = rand::sampleI32(tbl_row_rnd, 0, numTris); - float p = rand::sampleUniform(alias_p_rnd); - - AliasEntry tbl_row = triSampleAliasTable[tbl_row_idx]; - - uint32_t tri_idx = p < tbl_row.tau ? tbl_row_idx : tbl_row.alias; - *out_poly = tri_idx; - - Vector3 a, b, c; - getTriangleVertices(tri_idx, &a, &b, &c); - - Vector2 uv = rand::sample2xUniform(bary_rnd); - - if (uv.x + uv.y > 1.f) { - uv.x = 1.f - uv.x; - uv.y = 1.f - uv.y; - } - - float w = 1.f - uv.x - uv.y; - - return a * uv.x + b * uv.y + c * w; -} - -math::Vector3 Navmesh::samplePoint(RandKey rnd) -{ - uint32_t poly; - return samplePointAndPoly(rnd, &poly); -} - -void Navmesh::getTriangleVertices(uint32_t tri_idx, - math::Vector3 *out_a, - math::Vector3 *out_b, - math::Vector3 *out_c) -{ - *out_a = vertices[triIndices[3 * tri_idx]]; - *out_b = vertices[triIndices[3 * tri_idx + 1]]; - *out_c = vertices[triIndices[3 * tri_idx + 2]]; -} - -template -void Navmesh::bfsFromPoly(uint32_t start_poly, - BFSState bfs_state, - Fn &&fn) -{ - ArrayQueue bfs_queue(bfs_state.queue, numTris); - bool *visited = bfs_state.visited; - - utils::zeroN(visited, numTris); - - bfs_queue.add(start_poly); - visited[start_poly] = true; - - while (!bfs_queue.isEmpty()) { - uint32_t poly = bfs_queue.remove(); - - bool accept = fn(poly); - if (!accept) { - continue; - } - - MADRONA_UNROLL - for (CountT i = 0; i < 3; i++) { - uint32_t adjacent = triAdjacency[3 * poly + i]; - - if (adjacent != sentinel && !visited[adjacent]) { - bfs_queue.add(adjacent); - visited[adjacent] = true; - } - } - } -} - - -template -inline void Navmesh::dijkstrasFromPoly( - uint32_t start_poly, - math::Vector3 start_pos, - DijkstrasState dijkstras_state, - Fn &&fn) -{ - using namespace math; - - float *distances = dijkstras_state.distances; - - PathFindQueue prio_queue { - .costs = distances, - .heap = dijkstras_state.heap, - .heapIndex = dijkstras_state.heapIndex, - .heapSize = 0, - }; - utils::fillN(prio_queue.heapIndex, sentinel, numTris); - utils::fillN(distances, FLT_MAX, numTris); - - Vector3 *entry_points = dijkstras_state.entryPoints; - entry_points[start_poly] = start_pos; - - prio_queue.add(start_poly, 0.f); - while (prio_queue.heapSize > 0) { - uint32_t min_poly = prio_queue.removeMin(); - Vector3 cur_pos = entry_points[min_poly]; - float dist_so_far = distances[min_poly]; - - fn(min_poly, cur_pos, dist_so_far); - - Vector3 edge_midpoints[3]; - { - Vector3 a, b, c; - getTriangleVertices(min_poly, &a, &b, &c); - - edge_midpoints[0] = (a + b) / 2.f; - edge_midpoints[1] = (b + c) / 2.f; - edge_midpoints[2] = (c + a) / 2.f; - } - - MADRONA_UNROLL - for (CountT i = 0; i < 3; i++) { - uint32_t adjacent = triAdjacency[3 * min_poly + i]; - if (adjacent == Navmesh::sentinel) { - continue; - } - - Vector3 edge_midpoint = edge_midpoints[i]; - - float dist_to_edge = cur_pos.distance(edge_midpoint); - float new_dist = dist_so_far + dist_to_edge; - float prev_dist = distances[adjacent]; - - if (new_dist >= prev_dist) { - continue; - } - - entry_points[adjacent] = edge_midpoint; - - uint32_t prio_queue_idx = prio_queue.heapIndex[adjacent]; - if (prio_queue_idx == sentinel) { - prio_queue.add(adjacent, new_dist); - } else { - prio_queue.decreaseCost(adjacent, new_dist); - } - } - } -} - -} diff --git a/src/common/CMakeLists.txt b/src/common/CMakeLists.txt index 453905ea..0120357c 100644 --- a/src/common/CMakeLists.txt +++ b/src/common/CMakeLists.txt @@ -149,12 +149,6 @@ target_link_libraries(madrona_bvh_builder madrona_embree ) -add_library(madrona_navmesh STATIC - ${MADRONA_INC_DIR}/navmesh.hpp ${MADRONA_INC_DIR}/navmesh.inl navmesh.cpp -) - -target_link_libraries(madrona_navmesh PUBLIC madrona_common) - if (CUDAToolkit_FOUND) add_library(madrona_cuda STATIC ${MADRONA_INC_DIR}/cuda_utils.hpp ${MADRONA_INC_DIR}/cuda_utils.inl diff --git a/src/common/navmesh.cpp b/src/common/navmesh.cpp deleted file mode 100644 index bd47f87e..00000000 --- a/src/common/navmesh.cpp +++ /dev/null @@ -1,318 +0,0 @@ -#include -#include -#include - -namespace madrona { - -using namespace math; - -static inline CountT heapParent(CountT idx) -{ - return (idx - 1) / 2; -} - -static inline CountT heapChildOffset(CountT idx) -{ - return 2 * idx + 1; -} - -static inline void heapMoveUp(CountT moved_idx, - uint32_t moved_poly, - float moved_cost, - uint32_t *heap, - uint32_t *heap_index, - float *costs) -{ - while (moved_idx != 0) { - CountT parent_idx = heapParent(moved_idx); - uint32_t parent_poly = heap[parent_idx]; - if (costs[parent_poly] <= moved_cost) { - break; - } - - heap[moved_idx] = parent_poly; - heap_index[parent_poly] = moved_idx; - - moved_idx = parent_idx; - } - - heap[moved_idx] = moved_poly; - heap_index[moved_poly] = moved_idx; -} - -void Navmesh::PathFindQueue::add(uint32_t poly, float cost) -{ - costs[poly] = cost; - - CountT new_idx = heapSize++; - heapMoveUp(new_idx, poly, cost, heap, heapIndex, costs); -} - -uint32_t Navmesh::PathFindQueue::removeMin() -{ - uint32_t root_poly = heap[0]; - - uint32_t moved_poly = heap[--heapSize]; - float moved_cost = costs[moved_poly]; - - CountT moved_idx = 0; - CountT child_offset; - while ((child_offset = heapChildOffset(moved_idx)) < heapSize) { - CountT child_idx = child_offset; - uint32_t child_poly = heap[child_idx]; - float child_cost = costs[child_poly]; - { - // Pick the lowest cost child - CountT right_idx = child_idx + 1; - if (right_idx < heapSize) { - uint32_t right_poly = heap[right_idx]; - float right_cost = costs[right_poly]; - if (right_cost < child_cost) { - child_idx = right_idx; - child_poly = right_poly; - child_cost = right_cost; - } - } - } - - // moved_idx is now a valid position for moved_poly in the heap - if (moved_cost < child_cost) { - break; - } - - heap[moved_idx] = child_poly; - heapIndex[child_poly] = moved_idx; - - moved_idx = child_idx; - } - - heap[moved_idx] = moved_poly; - heapIndex[moved_poly] = moved_idx; - - heapIndex[root_poly] = Navmesh::sentinel; - return root_poly; -} - -void Navmesh::PathFindQueue::decreaseCost(uint32_t poly, float cost) -{ - costs[poly] = cost; - - CountT cur_idx = (CountT)heapIndex[poly]; - - heapMoveUp(cur_idx, poly, cost, heap, heapIndex, costs); -} - -static inline uint32_t hashNavmeshEdge(uint32_t a, uint32_t b) -{ - // MurmurHash2 Finalizer - - const uint32_t m = 0x5bd1e995; - - a ^= b >> 18; - a *= m; - b ^= a >> 22; - b *= m; - a ^= b >> 17; - a *= m; - b ^= a >> 19; - b *= m; - - return b; -} - -Navmesh Navmesh::initFromPolygons( - Vector3 *poly_vertices, - uint32_t *poly_idxs, - uint32_t *poly_idx_offsets, - uint32_t *poly_sizes, - uint32_t num_verts, - uint32_t num_polys) -{ - Vector3 *out_vertices = (Vector3 *)rawAlloc(sizeof(Vector3) * num_verts); - utils::copyN(out_vertices, poly_vertices, num_verts); - - uint32_t num_tris = 0; - for (CountT i = 0; i < (CountT)num_polys; i++) { - uint32_t poly_size = poly_sizes[i]; - - num_tris += poly_size - 2; - } - - uint32_t *tri_indices = - (uint32_t *)rawAlloc(sizeof(uint32_t) * 3 * num_tris); - uint32_t *tri_adjacency = - (uint32_t *)rawAlloc(sizeof(uint32_t) * 3 * num_tris); - - AliasEntry *alias_tbl = - (AliasEntry *)rawAlloc(sizeof(AliasEntry) * num_tris); - - // Temporary data - float *tri_weights = (float *)rawAlloc(sizeof(float) * num_tris); - uint32_t *alias_stack = - (uint32_t *)rawAlloc(sizeof(uint32_t) * num_tris * 2); - uint32_t *under_stack = alias_stack; - uint32_t *over_stack = alias_stack + num_tris; - uint32_t under_stack_size = 0; - uint32_t over_stack_size = 0; - - // Triangulate the input polygons - float tri_weight_sum = 0.f; - uint32_t cur_tri = 0; - for (CountT i = 0; i < (CountT)num_polys; i++) { - uint32_t poly_size = poly_sizes[i]; - uint32_t poly_idx_base = poly_idx_offsets[i]; - for (uint32_t tri_offset = 1; tri_offset < poly_size - 1; tri_offset++) { - uint32_t idx_a = poly_idxs[poly_idx_base]; - uint32_t idx_b = poly_idxs[poly_idx_base + tri_offset]; - uint32_t idx_c = poly_idxs[poly_idx_base + tri_offset + 1]; - - tri_indices[3 * cur_tri] = idx_a; - tri_indices[3 * cur_tri + 1] = idx_b; - tri_indices[3 * cur_tri + 2] = idx_c; - - Vector3 a = poly_vertices[idx_a]; - Vector3 b = poly_vertices[idx_b]; - Vector3 c = poly_vertices[idx_c]; - - Vector3 ab = b - a; - Vector3 ac = c - a; - float tri_area_x2 = cross(ab, ac).length(); - tri_weights[cur_tri] = tri_area_x2; - tri_weight_sum += tri_area_x2; - - cur_tri++; - } - } - - for (uint32_t tri_idx = 0; tri_idx < num_tris; tri_idx++) { - float normalized_weight = - tri_weights[tri_idx] * float(num_tris) / tri_weight_sum; - tri_weights[tri_idx] = normalized_weight; - - if (normalized_weight < 1.f) { - under_stack[under_stack_size++] = tri_idx; - } else { - over_stack[over_stack_size++] = tri_idx; - } - } - - while (under_stack_size != 0 && over_stack_size != 0) { - uint32_t under_idx = under_stack[--under_stack_size]; - uint32_t over_idx = over_stack[--over_stack_size]; - - alias_tbl[under_idx] = { - .tau = tri_weights[under_idx], - .alias = over_idx, - }; - - float new_over_weight = - (tri_weights[over_idx] + tri_weights[under_idx]) - 1.f; - tri_weights[over_idx] = new_over_weight; - - if (new_over_weight < 1.f) { - under_stack[under_stack_size++] = over_idx; - } else { - over_stack[over_stack_size++] = over_idx; - } - } - - for (uint32_t i = 0; i < under_stack_size; i++) { - uint32_t idx = under_stack[i]; - - alias_tbl[idx] = { - .tau = 1.f, - .alias = idx, // never accessed - }; - } - - for (uint32_t i = 0; i < over_stack_size; i++) { - uint32_t idx = over_stack[i]; - - alias_tbl[idx] = { - .tau = 1.f, - .alias = idx, // never accessed - }; - } - - rawDealloc(alias_stack); - rawDealloc(tri_weights); - - struct EdgeEntry { - uint32_t vertA; - uint32_t vertB; - uint32_t firstTriIdx; - uint32_t firstTriEdgeOffset; - }; - - uint32_t max_edges = num_tris * 3; - auto *edge_tbl = (EdgeEntry *)rawAlloc(sizeof(EdgeEntry) * max_edges); - - for (uint32_t i = 0; i < max_edges; i++) { - tri_adjacency[i] = sentinel; - edge_tbl[i] = { sentinel, sentinel, sentinel, 0 }; - } - - // This uses a super simple open addressing hash table. The max_edges bound - // is quite pessimistic, so shouldn't get too close to full on the hash - // table. - auto recordEdge = [edge_tbl, tri_adjacency, max_edges, out_vertices]( - uint32_t tri_idx, - uint32_t tri_edge_offset, - uint32_t a, uint32_t b) - { - if (b < a) { - std::swap(a, b); - } - - // Use Lemire fast modulo replacement trick - uint32_t edge_hash = utils::u32mulhi(hashNavmeshEdge(a, b), max_edges); - - while (edge_tbl[edge_hash].vertA != sentinel && ( - edge_tbl[edge_hash].vertA != a || - edge_tbl[edge_hash].vertB != b)) { - if (edge_hash == max_edges - 1) { - edge_hash = 0; - } else { - edge_hash += 1; - } - } - - EdgeEntry &entry = edge_tbl[edge_hash]; - entry.vertA = a; - entry.vertB = b; - - if (entry.firstTriIdx == sentinel) { - entry.firstTriIdx = tri_idx; - entry.firstTriEdgeOffset = tri_edge_offset; - } else { - uint32_t other_tri_idx = entry.firstTriIdx; - uint32_t other_tri_edge_offset = entry.firstTriEdgeOffset; - - tri_adjacency[3 * tri_idx + tri_edge_offset] = other_tri_idx; - tri_adjacency[3 * other_tri_idx + other_tri_edge_offset] = tri_idx; - } - }; - - for (uint32_t tri_idx = 0; tri_idx < num_tris; tri_idx++) { - uint32_t a_idx = tri_indices[3 * tri_idx]; - uint32_t b_idx = tri_indices[3 * tri_idx + 1]; - uint32_t c_idx = tri_indices[3 * tri_idx + 2]; - - recordEdge(tri_idx, 0, a_idx, b_idx); - recordEdge(tri_idx, 1, b_idx, c_idx); - recordEdge(tri_idx, 2, c_idx, a_idx); - } - - rawDealloc(edge_tbl); - - return Navmesh { - .vertices = out_vertices, - .triIndices = tri_indices, - .triAdjacency = tri_adjacency, - .triSampleAliasTable = alias_tbl, - .numVerts = num_verts, - .numTris = num_tris, - }; -} - -} diff --git a/src/mw/CMakeLists.txt b/src/mw/CMakeLists.txt index 0bef14bd..ee7d8620 100644 --- a/src/mw/CMakeLists.txt +++ b/src/mw/CMakeLists.txt @@ -80,7 +80,6 @@ set(TASK_GRAPH_DEVICE_SOURCES ${DEVICE_SRC_DIR}/ecs_render_bridge.cpp # FIXME ${MADRONA_SRC_DIR}/common/hashmap.cpp - ${MADRONA_SRC_DIR}/common/navmesh.cpp ${MADRONA_SRC_DIR}/core/base.cpp # Genesis drives physics itself and feeds transforms to the renderer, so the # madrona rigid-body simulation is not part of the render task graph. Only From 02bb33799f3e6698bdf91de63bbbdcc2868376ca Mon Sep 17 00:00:00 2001 From: Alexis Duburcq Date: Thu, 23 Jul 2026 09:36:33 +0200 Subject: [PATCH 05/14] Remove irrelevant comment --- src/mw/CMakeLists.txt | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/mw/CMakeLists.txt b/src/mw/CMakeLists.txt index ee7d8620..148c3739 100644 --- a/src/mw/CMakeLists.txt +++ b/src/mw/CMakeLists.txt @@ -81,9 +81,6 @@ set(TASK_GRAPH_DEVICE_SOURCES # FIXME ${MADRONA_SRC_DIR}/common/hashmap.cpp ${MADRONA_SRC_DIR}/core/base.cpp - # Genesis drives physics itself and feeds transforms to the renderer, so the - # madrona rigid-body simulation is not part of the render task graph. Only - # geo.cpp is kept (shared geometry helpers used by the mesh BVH). ${MADRONA_SRC_DIR}/physics/geo.cpp ${MADRONA_SRC_DIR}/render/ecs_system.cpp ) From 1a5aaed83c3fb98b2029cddc74e1263617f67444 Mon Sep 17 00:00:00 2001 From: Alexis Duburcq Date: Thu, 23 Jul 2026 09:39:52 +0200 Subject: [PATCH 06/14] Remove the unused CPU multi-world executor The batch renderer runs GPU-only (ExecMode::CUDA / MWCudaExecutor); the CPU executor (madrona_mw_cpu: cpu_exec.cpp + mw_cpu headers) is never instantiated. Drop the library, its bridge link, and the vestigial mw_cpu.hpp include in mgr. --- include/madrona/mw_cpu.hpp | 114 ------------------ include/madrona/mw_cpu.inl | 113 ------------------ src/bridge/CMakeLists.txt | 1 - src/bridge/mgr.cpp | 1 - src/mw/CMakeLists.txt | 9 -- src/mw/cpu_exec.cpp | 239 ------------------------------------- 6 files changed, 477 deletions(-) delete mode 100644 include/madrona/mw_cpu.hpp delete mode 100644 include/madrona/mw_cpu.inl delete mode 100644 src/mw/cpu_exec.cpp diff --git a/include/madrona/mw_cpu.hpp b/include/madrona/mw_cpu.hpp deleted file mode 100644 index c10a989a..00000000 --- a/include/madrona/mw_cpu.hpp +++ /dev/null @@ -1,114 +0,0 @@ -/* - * Copyright 2021-2023 Brennan Shacklett and contributors - * - * Use of this source code is governed by an MIT-style - * license that can be found in the LICENSE file or at - * https://opensource.org/licenses/MIT. - */ -#pragma once - -#include -#include -#include - -namespace madrona { - -// Base class for TaskGraphExecutor below, don't use directly -class ThreadPoolExecutor { -public: - struct Config { - // Batch size for the backend - uint32_t numWorlds; - // Number of exported ECS components - uint32_t numExportedBuffers; - // Number of worker threads - uint32_t numWorkers = 0; - }; - - struct Job { - void (*fn)(void *); - void *data; - }; - - ThreadPoolExecutor(const Config &cfg); - ThreadPoolExecutor(ThreadPoolExecutor &&o); - - ~ThreadPoolExecutor(); - void run(Job *jobs, CountT num_jobs); - - // Get the base pointer of the component data exported with - // ECSRegister::exportColumn - void * getExported(CountT slot) const; - -protected: - void initializeContexts( - Context & (*init_fn)(void *, const WorkerInit &, CountT), - void *init_data, CountT num_worlds); - - ECSRegistry getECSRegistry(); - - void initExport(); - -private: - struct Impl; - std::unique_ptr impl_; -}; - -// The TaskGraphExecutor class is the entry point for the CPU backend. -// Use as follows: -// using MyCPUBackend = TaskGraphExecutor< -// MyContextSubclass, MyPerWorldState, MyConfig, MyPerWorldInit>; -// -// MyCPUBackend backend({ -// .numWorlds = 1024, -// .numExportedBuffers = 5, // Make sure this is set correctly! -// .numWorkers = 0, // (Autodetect number of CPU cores) -// }, MyConfig {}, my_world_inits); -// -// backend.run(); // Take one step -// -// The above code will initialize the simulation state with -// 1024 copies of the MyPerWorldState class, passing MyConfig and the -// appropriate my_world_inits reference to the MyPerWorldState constructor -template -class TaskGraphExecutor : private ThreadPoolExecutor { -public: - TaskGraphExecutor( - const Config &cfg, - const ConfigT &user_cfg, - const InitT *user_inits, - CountT num_taskgraphs); - - // Run one invocation of the task graph across all worlds (one step) - template - inline void runTaskGraph(EnumT taskgraph_id); - - inline void runTaskGraph(uint32_t taskgraph_idx); - - inline void run(); - - // Get the base pointer of the component data exported with - // ECSRegister::exportColumn - using ThreadPoolExecutor::getExported; - - // Get a reference to the per world data class - inline WorldT & getWorldData(CountT world_idx); - - inline ContextT & getWorldContext(CountT idx); - -private: - struct JobData { - Context *ctx; - TaskGraph taskgraph; - }; - - HeapArray contexts_; - HeapArray world_datas_; - HeapArray job_datas_; - HeapArray jobs_; - uint32_t num_taskgraphs_; -}; - -} - -#include "mw_cpu.inl" diff --git a/include/madrona/mw_cpu.inl b/include/madrona/mw_cpu.inl deleted file mode 100644 index af9faba9..00000000 --- a/include/madrona/mw_cpu.inl +++ /dev/null @@ -1,113 +0,0 @@ -#pragma once - -namespace madrona { - -template -TaskGraphExecutor::TaskGraphExecutor( - const Config &cfg, - const ConfigT &user_cfg, - const InitT *user_inits, - CountT num_taskgraphs) - : ThreadPoolExecutor(cfg), - contexts_(cfg.numWorlds), - world_datas_(cfg.numWorlds), - job_datas_((CountT)cfg.numWorlds * num_taskgraphs), - jobs_((CountT)cfg.numWorlds * num_taskgraphs), - num_taskgraphs_((uint32_t)num_taskgraphs) -{ - auto ecs_reg = getECSRegistry(); - WorldT::registerTypes(ecs_reg, user_cfg); - - HeapArray taskgraph_mgrs(cfg.numWorlds); - - auto ctx_init_cb = [&](const WorkerInit &worker_init, - CountT world_idx) -> Context & { - WorldT *world_data_ptr = &world_datas_[world_idx]; - new (&contexts_[world_idx]) Context(world_data_ptr, worker_init); - - taskgraph_mgrs.emplace(world_idx, num_taskgraphs, worker_init); - - return contexts_[world_idx]; - }; - - using CBPtrT = decltype(&ctx_init_cb); - initializeContexts([](void *ptr_raw, - const WorkerInit &worker_init, - CountT world_idx) -> Context & { - return (*(CBPtrT)ptr_raw)(worker_init, world_idx); - }, &ctx_init_cb, cfg.numWorlds); - - for (CountT world_idx = 0; world_idx < (CountT)cfg.numWorlds; - world_idx++) { - world_datas_.emplace(world_idx, contexts_[world_idx], - user_cfg, user_inits[world_idx]); - } - - HeapArray> built_graphs(cfg.numWorlds); - for (CountT world_idx = 0; world_idx < (CountT)cfg.numWorlds; - world_idx++) { - WorldT::setupTasks(taskgraph_mgrs[world_idx], user_cfg); - - built_graphs.emplace(world_idx, - taskgraph_mgrs[world_idx].constructGraphs()); - } - - for (CountT taskgraph_idx = 0; taskgraph_idx < num_taskgraphs; - taskgraph_idx++) { - for (CountT world_idx = 0; world_idx < (CountT)cfg.numWorlds; - world_idx++) { - CountT job_idx = taskgraph_idx * cfg.numWorlds + world_idx; - - job_datas_.emplace(job_idx, JobData { - .ctx = &contexts_[world_idx], - .taskgraph = std::move(built_graphs[world_idx][taskgraph_idx]), - }); - - jobs_[job_idx].fn = [](void *ptr) { - auto job_data = (JobData *)ptr; - job_data->taskgraph.run(job_data->ctx); - }; - jobs_[job_idx].data = &job_datas_[job_idx]; - } - } - - initExport(); -} - -template -template -void TaskGraphExecutor::runTaskGraph(EnumT taskgraph_id) -{ - runTaskGraph(static_cast(taskgraph_id)); -} - -template -void TaskGraphExecutor::runTaskGraph(uint32_t taskgraph_idx) -{ - CountT offset = taskgraph_idx * world_datas_.size(); - ThreadPoolExecutor::run(jobs_.data() + offset, world_datas_.size()); -} - -template -void TaskGraphExecutor::run() -{ - for (uint32_t i = 0; i < (uint32_t)num_taskgraphs_; i++) { - runTaskGraph(i); - } -} - -template -WorldT & TaskGraphExecutor::getWorldData( - CountT world_idx) -{ - return world_datas_[world_idx]; -} - -template -ContextT & TaskGraphExecutor::getWorldContext( - CountT world_idx) -{ - return contexts_[world_idx]; -} - -} diff --git a/src/bridge/CMakeLists.txt b/src/bridge/CMakeLists.txt index 1a3b6077..2f0c3faf 100644 --- a/src/bridge/CMakeLists.txt +++ b/src/bridge/CMakeLists.txt @@ -33,7 +33,6 @@ target_link_libraries(madgs_mgr PRIVATE madrona_python_utils madgs_cpu_impl - madrona_mw_cpu madrona_common madrona_importer madrona_render diff --git a/src/bridge/mgr.cpp b/src/bridge/mgr.cpp index 0b118e90..00bd9ca9 100644 --- a/src/bridge/mgr.cpp +++ b/src/bridge/mgr.cpp @@ -5,7 +5,6 @@ #include #include #include -#include #include #include diff --git a/src/mw/CMakeLists.txt b/src/mw/CMakeLists.txt index 148c3739..b3012ea8 100644 --- a/src/mw/CMakeLists.txt +++ b/src/mw/CMakeLists.txt @@ -1,12 +1,3 @@ -add_library(madrona_mw_cpu STATIC - ${MADRONA_INC_DIR}/mw_cpu.hpp ${MADRONA_INC_DIR}/mw_cpu.inl cpu_exec.cpp -) - -target_link_libraries(madrona_mw_cpu - PUBLIC - madrona_mw_core -) - if (NOT CUDAToolkit_FOUND OR CUDAToolkit_VERSION_MAJOR LESS 12 OR (CUDAToolkit_VERSION_MAJOR EQUAL 12 AND CUDAToolkit_VERSION_MINOR LESS 8)) return() diff --git a/src/mw/cpu_exec.cpp b/src/mw/cpu_exec.cpp deleted file mode 100644 index 68b1b457..00000000 --- a/src/mw/cpu_exec.cpp +++ /dev/null @@ -1,239 +0,0 @@ -#include -#include "../core/worker_init.hpp" - -#if defined(MADRONA_LINUX) or defined(MADRONA_MACOS) -#include -#elif defined(MADRONA_WINDOWS) -#include -#endif - -namespace madrona { - -struct ThreadPoolExecutor::Impl { - HeapArray workers; - alignas(MADRONA_CACHE_LINE) AtomicI32 workerWakeup; - alignas(MADRONA_CACHE_LINE) AtomicI32 mainWakeup; - ThreadPoolExecutor::Job *currentJobs; - uint32_t numJobs; - alignas(MADRONA_CACHE_LINE) AtomicU32 nextJob; - alignas(MADRONA_CACHE_LINE) AtomicU32 numFinished; - StateManager stateMgr; - HeapArray stateCaches; - HeapArray exportPtrs; - - static Impl * make(const ThreadPoolExecutor::Config &cfg); - ~Impl(); - void run(Job *jobs, CountT num_jobs); - void workerThread(CountT worker_id); -}; - -static CountT getNumCores() -{ -#if defined(MADRONA_MACOS) - int os_num_threads = sysconf(_SC_NPROCESSORS_ONLN); - - if (os_num_threads <= 0) { - FATAL("Failed to get number of concurrent threads"); - } - - return os_num_threads; -#elif defined(MADRONA_LINUX) - cpu_set_t cpuset; - pthread_getaffinity_np(pthread_self(), sizeof(cpuset), &cpuset); - CountT num_active_threads = CPU_COUNT(&cpuset); - if (num_active_threads <= 0) { - FATAL("Failed to get number of concurrent threads"); - } - - return num_active_threads; -#elif defined(MADRONA_WINDOWS) - SYSTEM_INFO sys_info; - GetSystemInfo(&sys_info); - return sys_info.dwNumberOfProcessors; -#else - STATIC_UNIMPLEMENTED(); -#endif -} - -static inline void pinThread([[maybe_unused]] CountT worker_id) -{ -#ifdef MADRONA_LINUX - cpu_set_t cpu_set; - pthread_getaffinity_np(pthread_self(), sizeof(cpu_set), &cpu_set); - - const int max_threads = CPU_COUNT(&cpu_set); - - if (worker_id > max_threads) [[unlikely]] { - FATAL("Tried setting thread affinity to %d when %d is max", - worker_id, max_threads); - } - - cpu_set_t worker_set; - CPU_ZERO(&worker_set); - - // This is needed in case there was already cpu masking via - // a different call to setaffinity or via cgroup (SLURM) - for (CountT thread_idx = 0, available_threads = 0; - thread_idx < (CountT)CPU_SETSIZE; thread_idx++) { - if (CPU_ISSET(thread_idx, &cpu_set)) { - if ((available_threads++) == worker_id) { - CPU_SET(thread_idx, &worker_set); - - break; - } - } - } - - int res = pthread_setaffinity_np(pthread_self(), - sizeof(worker_set), - &worker_set); - - if (res != 0) { - FATAL("Failed to set thread affinity to %d", worker_id); - } -#endif -} - -ThreadPoolExecutor::Impl * ThreadPoolExecutor::Impl::make( - const ThreadPoolExecutor::Config &cfg) -{ - Impl *impl = new Impl { - .workers = HeapArray( - cfg.numWorkers == 0 ? getNumCores() : cfg.numWorkers), - .workerWakeup = 0, - .mainWakeup = 0, - .currentJobs = nullptr, - .numJobs = 0, - .nextJob = 0, - .numFinished = 0, - .stateMgr = StateManager(cfg.numWorlds), - .stateCaches = HeapArray(cfg.numWorlds), - .exportPtrs = HeapArray(cfg.numExportedBuffers), - }; - - for (CountT i = 0; i < (CountT)cfg.numWorlds; i++) { - impl->stateCaches.emplace(i); - } - - for (CountT i = 0; i < impl->workers.size(); i++) { - impl->workers.emplace(i, [](Impl *impl, CountT i) { - impl->workerThread(i); - }, impl, i); - } - - return impl; -} - -ThreadPoolExecutor::ThreadPoolExecutor(const Config &cfg) - : impl_(Impl::make(cfg)) -{} - -ThreadPoolExecutor::ThreadPoolExecutor(ThreadPoolExecutor &&o) = default; - -ThreadPoolExecutor::Impl::~Impl() -{ - workerWakeup.store_release(-1); - workerWakeup.notify_all(); - - for (CountT i = 0; i < workers.size(); i++) { - workers[i].join(); - } -} - -ThreadPoolExecutor::~ThreadPoolExecutor() = default; - -void ThreadPoolExecutor::Impl::run(Job *jobs, CountT num_jobs) -{ - stateMgr.copyInExportedColumns(); - - currentJobs = jobs; - numJobs = uint32_t(num_jobs); - nextJob.store_relaxed(0); - numFinished.store_relaxed(0); - workerWakeup.store_release(1); - workerWakeup.notify_all(); - - mainWakeup.wait(0); - mainWakeup.store_relaxed(0); - - stateMgr.copyOutExportedColumns(); -} - -void ThreadPoolExecutor::run(Job *jobs, CountT num_jobs) -{ - impl_->run(jobs, num_jobs); -} - -void * ThreadPoolExecutor::getExported(CountT slot) const -{ - return impl_->exportPtrs[slot]; -} - -void ThreadPoolExecutor::initializeContexts( - Context & (*init_fn)(void *, const WorkerInit &, CountT), - void *init_data, CountT num_worlds) -{ - for (CountT world_idx = 0; world_idx < num_worlds; world_idx++) { - WorkerInit worker_init { - &impl_->stateMgr, - &impl_->stateCaches[world_idx], - uint32_t(world_idx), - }; - - init_fn(init_data, worker_init, world_idx); - } -} - -ECSRegistry ThreadPoolExecutor::getECSRegistry() -{ - return ECSRegistry(&impl_->stateMgr, impl_->exportPtrs.data()); -} - -void ThreadPoolExecutor::initExport() -{ - impl_->stateMgr.copyOutExportedColumns(); -} - -void ThreadPoolExecutor::Impl::workerThread(CountT worker_id) -{ - pinThread(worker_id); - - while (true) { - workerWakeup.wait(0); - int32_t ctrl = workerWakeup.load_acquire(); - - if (ctrl == 0) { - continue; - } else if (ctrl == -1) { - break; - } - - while (true) { - uint32_t job_idx = nextJob.fetch_add_relaxed(1); - - if (job_idx == numJobs) { - workerWakeup.store_relaxed(0); - } - - assert(job_idx < 0xFFFF'FFFF); - - if (job_idx >= numJobs) { - break; - } - - currentJobs[job_idx].fn(currentJobs[job_idx].data); - - // This has to be acq_rel so the finishing thread has seen - // all the other threads' effects - uint32_t prev_finished = - numFinished.fetch_add_acq_rel(1); - - if (prev_finished == numJobs - 1) { - mainWakeup.store_release(1); - mainWakeup.notify_one(); - } - } - } -} - -} From de303a0e8662dc3776cd5a2c66ea2400d80c0c47 Mon Sep 17 00:00:00 2001 From: Alexis Duburcq Date: Thu, 23 Jul 2026 11:03:31 +0200 Subject: [PATCH 07/14] Remove dead importer format branches and the unlinked madrona_core lib - importer.cpp: drop the permanently-disabled #ifdef MADRONA_GLTF_SUPPORT / MADRONA_USD_SUPPORT branches (macros defined nowhere; they include gltf.hpp / usd.hpp which don't exist). Only OBJ + procedural primitives are used. - src/core: remove the madrona_core static library. It is never linked by any target (the bridge and rendering system use madrona_mw_core); it only redundantly recompiled the core sources. --- src/core/CMakeLists.txt | 14 ----------- src/importer/importer.cpp | 49 --------------------------------------- 2 files changed, 63 deletions(-) diff --git a/src/core/CMakeLists.txt b/src/core/CMakeLists.txt index fc3daf17..29b1eefd 100644 --- a/src/core/CMakeLists.txt +++ b/src/core/CMakeLists.txt @@ -11,20 +11,6 @@ set(MADRONA_CORE_SRCS # platform_utils.cpp ) -add_library(madrona_core STATIC - ${MADRONA_CORE_SRCS} -) - -target_link_libraries(madrona_core - PUBLIC - madrona_common -) - -target_compile_definitions(madrona_core - PUBLIC - -DMADRONA_USE_TASK_GRAPH=1 -) - add_library(madrona_mw_core STATIC ${MADRONA_CORE_SRCS} ${MADRONA_INC_DIR}/taskgraph.hpp ${MADRONA_INC_DIR}/taskgraph.inl taskgraph.cpp diff --git a/src/importer/importer.cpp b/src/importer/importer.cpp index dc3d7186..f039bf47 100644 --- a/src/importer/importer.cpp +++ b/src/importer/importer.cpp @@ -12,14 +12,6 @@ #include "obj.hpp" -#ifdef MADRONA_GLTF_SUPPORT -#include "gltf.hpp" -#endif - -#ifdef MADRONA_USD_SUPPORT -#include "usd.hpp" -#endif - #ifdef MADRONA_CUDA_SUPPORT #include #endif @@ -33,14 +25,6 @@ struct AssetImporter::Impl { Optional objLoader; -#ifdef MADRONA_GLTF_SUPPORT - Optional gltfLoader; -#endif - -#ifdef MADRONA_USD_SUPPORT - Optional usdLoader; -#endif - static inline Impl * make(ImageImporter &&img_importer); inline Optional importFromDisk( @@ -53,12 +37,6 @@ AssetImporter::Impl * AssetImporter::Impl::make(ImageImporter &&img_importer) return new Impl { .imgImporter = std::move(img_importer), .objLoader = Optional::none(), -#ifdef MADRONA_GLTF_SUPPORT - .gltfLoader = Optional::none(), -#endif -#ifdef MADRONA_USD_SUPPORT - .usdLoader = Optional::none(), -#endif }; } @@ -103,33 +81,6 @@ Optional AssetImporter::Impl::importFromDisk( } load_success = objLoader->load(path, imported); - } else if (extension == "gltf" || extension == "glb") { -#ifdef MADRONA_GLTF_SUPPORT - if (!gltfLoader.has_value()) { - gltfLoader.emplace(imgImporter, err_buf); - } - - load_success = gltfLoader->load( - path, imported, one_object_per_asset, imgImporter); -#else - load_success = false; - snprintf(err_buf.data(), err_buf.size(), "Madrona not compiled with glTF support"); -#endif - } else if (extension == "usd" || - extension == "usda" || - extension == "usdc" || - extension == "usdz") { -#ifdef MADRONA_USD_SUPPORT - if (!usdLoader.has_value()) { - usdLoader.emplace(imgImporter, err_buf); - } - - load_success = usdLoader->load( - path, imported, one_object_per_asset, imgImporter); -#else - load_success = false; - snprintf(err_buf.data(), err_buf.size(), "Madrona not compiled with USD support"); -#endif } if (!load_success) { From 5bb9b4873e0e45b966f89cd7e43911ffdc1093fc Mon Sep 17 00:00:00 2001 From: Alexis Duburcq Date: Thu, 23 Jul 2026 13:27:16 +0200 Subject: [PATCH 08/14] Remove the dead JobSystem megakernel execution path The GPU executor is hardcoded to TaskGraph (exec_mode was a compile-time constant) and MADRONA_USE_JOB_SYSTEM is never defined, so the entire JobSystem path was unreachable: device/host job managers, the job megakernel body, the per-mode branches in cuda_exec.cpp, and the orphaned getUserEntries / makeJobSysRunGraph helpers. --- include/madrona/custom_context.hpp | 1 - include/madrona/fwd.hpp | 1 - include/madrona/job.hpp | 272 ---- include/madrona/job.inl | 304 ---- src/core/CMakeLists.txt | 1 - src/core/job.cpp | 1668 ---------------------- src/mw/CMakeLists.txt | 14 - src/mw/cuda_exec.cpp | 273 +--- src/mw/device/include/madrona/job.hpp | 67 - src/mw/device/include/madrona/job.inl | 109 -- src/mw/device/include/madrona/mw_gpu.hpp | 26 - src/mw/device/include/madrona/mw_gpu.inl | 50 - src/mw/device/job.cpp | 999 ------------- src/mw/device/megakernel_job_impl.inl | 62 - src/mw/device/work_queue.hpp | 42 - src/mw/functionality.cpp | 15 - 16 files changed, 25 insertions(+), 3879 deletions(-) delete mode 100644 include/madrona/job.hpp delete mode 100644 include/madrona/job.inl delete mode 100644 src/core/job.cpp delete mode 100644 src/mw/device/include/madrona/job.hpp delete mode 100644 src/mw/device/include/madrona/job.inl delete mode 100644 src/mw/device/include/madrona/mw_gpu.hpp delete mode 100644 src/mw/device/include/madrona/mw_gpu.inl delete mode 100644 src/mw/device/job.cpp delete mode 100644 src/mw/device/megakernel_job_impl.inl delete mode 100644 src/mw/device/work_queue.hpp delete mode 100644 src/mw/functionality.cpp diff --git a/include/madrona/custom_context.hpp b/include/madrona/custom_context.hpp index 1ef8f375..6e60defd 100644 --- a/include/madrona/custom_context.hpp +++ b/include/madrona/custom_context.hpp @@ -23,7 +23,6 @@ class CustomContext : public Context { private: using WorldDataT = DataT; -friend class JobManager; friend class TaskGraph; }; diff --git a/include/madrona/fwd.hpp b/include/madrona/fwd.hpp index f205451e..11358a2f 100644 --- a/include/madrona/fwd.hpp +++ b/include/madrona/fwd.hpp @@ -9,7 +9,6 @@ namespace madrona { -class JobManager; class StateCache; class StateManager; class ECSRegistry; diff --git a/include/madrona/job.hpp b/include/madrona/job.hpp deleted file mode 100644 index 3339bf70..00000000 --- a/include/madrona/job.hpp +++ /dev/null @@ -1,272 +0,0 @@ -/* - * Copyright 2021-2022 Brennan Shacklett and contributors - * - * Use of this source code is governed by an MIT-style - * license that can be found in the LICENSE file or at - * https://opensource.org/licenses/MIT. - */ -#pragma once - -#include -#include -#include -#include -#include - -#include -#include -#include -#include - -namespace madrona { - -enum class JobPriority { - High, - Normal, - IO, -}; - -struct JobID { - uint32_t gen; - int32_t id; - - static constexpr inline JobID none(); -}; - -struct JobContainerBase { - JobID id; - uint32_t jobSize; -#ifdef MADRONA_MW_MODE - uint32_t worldID; -#endif - uint32_t numDependencies; - - template struct DepsArray; -}; - -template -struct JobContainer : public JobContainerBase { - [[no_unique_address]] DepsArray dependencies; - [[no_unique_address]] Fn fn; - - template - inline JobContainer(uint32_t job_size, MADRONA_MW_COND(uint32_t world_id,) - Fn &&fn, DepTs ...deps); -}; - -struct Job { - void (*func)(); - JobContainerBase *data; - uint32_t invocationOffset; - uint32_t numInvocations; -}; - -class JobManager { -public: - template struct EntryConfig; - - template - static EntryConfig makeEntry( - StartFn &&start_fn); - - template - static EntryConfig makeEntry(StartFn &&start_fn, - UpdateFn &&update_fn); - - template - JobManager(const EntryConfig &entry_cfg, - int desired_num_workers, - int num_io, - StateManager *state_mgr, - bool pin_workers = true); - - ~JobManager(); - - inline JobID reserveProxyJobID(int thread_idx, JobID parent_id); - inline void relinquishProxyJobID(int thread_idx, JobID job_id); - - template - JobID queueJob(int thread_idx, - Fn &&fn, - uint32_t num_invocations, - JobID parent_id, - MADRONA_MW_COND(uint32_t world_id,) - JobPriority prio = JobPriority::Normal, - DepTs ...deps); - -#if 0 - JobID queueJobs(int thread_idx, JobID parent_id, - const Job *jobs, uint32_t num_jobs, - const JobID *deps, uint32_t num_dependencies, - JobPriority prio = JobPriority::Normal); -#endif - - void waitForAllFinished(); - - // Custom allocator that recycles small arenas out of a large chunk of - // preallocated memory. - class Alloc { - public: - struct Arena { - // Doubles as next pointer in freelist or - // count of num bytes freed before being put on freelist - AtomicU32 metadata; - }; - - struct SharedState { - void *memoryBase; - void *jobMemory; - Arena *arenas; - AtomicU32 freeHead; - }; - - static constexpr size_t maxJobSize = 1024; - static constexpr size_t maxJobAlignment = 128; - - Alloc(SharedState &shared); - void * alloc(SharedState &shared, uint32_t num_bytes, uint32_t alignment); - void dealloc(SharedState &shared, void *ptr, uint32_t num_bytes); - - // FIXME: fix InitAlloc ownership - static SharedState makeSharedState(InitAlloc alloc, - uint32_t num_arenas); - - private: - static constexpr size_t arena_size_ = 4096; - - uint32_t cur_arena_; - uint32_t next_arena_; - uint32_t arena_offset_; - uint32_t arena_used_bytes_; - }; - - struct RunQueue { - AtomicU32 head; - AtomicU32 correction; - AtomicU32 auth; - char pad[MADRONA_CACHE_LINE - sizeof(AtomicU32) * 3]; - AtomicU32 tail; - }; - -private: - struct SchedulerState { - uint32_t numWaiting; - uint32_t numSleepingWorkers; - SpinLock lock; - }; - - template - static void singleInvokeEntry(Context *ctx_base, JobContainerBase *data); - - template - static void multiInvokeEntry(Context *ctx_base, JobContainerBase *data, - uint64_t invocation_offset, - uint64_t num_invocations, - RunQueue *thread_queue); - - using SingleInvokeFn = decltype(&singleInvokeEntry); - using MultiInvokeFn = decltype(&multiInvokeEntry); - - JobManager(uint32_t num_ctx_userdata_bytes, - uint32_t ctx_userdata_alignment, - void (*ctx_init_fn)(void *, void *, WorkerInit &&), - uint32_t num_ctx_bytes, - uint32_t ctx_alignment, - void (*start_fn)(Context *, void *), - void *start_fn_data, - void (*update_fn)(Context *, void *), - void *update_fn_data, - int desired_num_workers, - int num_io, - StateManager *state_mgr, - bool pin_workers); - - struct Init; - JobManager(const Init &init); - - inline void * allocJob(int worker_idx, uint32_t num_bytes, - uint32_t alignment); - inline void deallocJob(int worker_idx, void *ptr, uint32_t num_bytes); - - JobID queueJob(int thread_idx, void (*job_func)(), - JobContainerBase *job_data, uint32_t num_invocations, - uint32_t parent_job_idx, - JobPriority prio = JobPriority::Normal); - - JobID reserveProxyJobID(int thread_idx, uint32_t parent_job_idx); - void relinquishProxyJobID(int thread_idx, uint32_t job_idx); - - void markInvocationsFinished(int thread_idx, - JobContainerBase *job_data, - int32_t job_idx, - uint32_t num_invocations); - - inline JobID getNewJobID(int thread_idx, uint32_t parent_job_idx, - uint32_t num_invocations); - - template - inline void addToRunQueue(int thread_idx, JobPriority prio, Fn &&add_cb); - - inline void addToWaitQueue(int thread_idx, void (*job_func)(), - JobContainerBase *job_data, uint32_t num_invocations, - JobPriority prio); - - enum class WorkerControl : uint64_t; - inline WorkerControl schedule(int thread_idx, Job *run_job); - - inline bool isQueueEmpty(uint32_t head, uint32_t correction, - uint32_t tail) const; - - inline uint32_t dequeueJobIndex(RunQueue *run_queue); - - inline WorkerControl getNextJob(void *queue_base, int thread_idx, - int init_search_idx, - bool run_scheduler, - Job *job); - - inline WorkerControl tryScheduling(JobManager::WorkerControl default_ctrl, - int thread_idx, Job *next_job); - - inline bool shouldSplitJob(RunQueue *queue) const; - - void splitJob(MultiInvokeFn fn_ptr, JobContainerBase *job_data, - uint32_t invocation_offset, uint32_t num_invocations, - RunQueue *run_queue); - - inline void runJob(const int thread_idx, Context *ctx, - void (*generic_fn)(), JobContainerBase *job_data, - uint32_t invocation_offset, uint32_t num_invocations); - - void workerThread(const int thread_idx, - void *context_base, - uint32_t num_context_bytes); - - void ioThread(const int thread_idx, - void *context_base, - uint32_t num_context_bytes); - - HeapArray threads_; - - Alloc::SharedState alloc_state_; - HeapArray job_allocs_; - - SchedulerState scheduler_; - void *const state_ptr_; - void *const high_base_; - void *const normal_base_; - void *const io_base_; - void *const tracker_base_; - void *const tracker_cache_base_; - void *const worker_base_; - void *const log_base_; - void *const waiting_jobs_; - uint32_t num_compute_workers_; - - std::counting_semaphore<> io_sema_; - alignas(MADRONA_CACHE_LINE) AtomicU32 num_high_; -}; - -} - -#include "job.inl" diff --git a/include/madrona/job.inl b/include/madrona/job.inl deleted file mode 100644 index 3df94267..00000000 --- a/include/madrona/job.inl +++ /dev/null @@ -1,304 +0,0 @@ -/* - * Copyright 2021-2022 Brennan Shacklett and contributors - * - * Use of this source code is governed by an MIT-style - * license that can be found in the LICENSE file or at - * https://opensource.org/licenses/MIT. - */ -#pragma once - -#include - -namespace madrona { - -constexpr JobID JobID::none() -{ - return JobID { - 0xFFFF'FFFF_u32, - 0xFFFF'FFFF_i32, - }; -} - -template -struct JobContainerBase::DepsArray { - JobID dependencies[N]; - - template - inline DepsArray(DepTs ...deps) - : dependencies { deps ... } - {} -}; - -template <> struct JobContainerBase::DepsArray<0> { - template - inline DepsArray(DepTs...) {} -}; - -template -template -JobContainer::JobContainer(uint32_t job_size, - MADRONA_MW_COND(uint32_t world_id,) - Fn &&func, - DepTs ...deps) - : JobContainerBase { - .id = JobID::none(), // Assigned in JobManager::queueJob - .jobSize = job_size, - MADRONA_MW_COND(.worldID = world_id,) - .numDependencies = N, - }, - dependencies(deps...), - fn(std::forward(func)) -{} - -bool JobManager::isQueueEmpty(uint32_t head, - uint32_t correction, - uint32_t tail) const -{ - auto checkGEWrapped = [](uint32_t a, uint32_t b) { - return a - b <= (1u << 31u); - }; - - return checkGEWrapped(head - correction, tail); -} - -template -struct JobManager::EntryConfig { - uint32_t numUserdataBytes; - uint32_t userdataAlignment; - void (*ctxInitCB)(void *, void *, WorkerInit &&); - uint32_t numCtxBytes; - uint32_t ctxAlignment; - StartFn startFnData; - void (*startWrapper)(Context *, void *); - UpdateFn updateFnData; - void (*updateLoop)(Context *, void *); -}; - -template -JobManager::EntryConfig - JobManager::makeEntry(StartFn &&start_fn) -{ - return makeEntry( - std::forward(start_fn), nullptr); -} - -template -JobManager::EntryConfig JobManager::makeEntry( - StartFn &&start_fn, UpdateFn &&update_fn) -{ - static_assert(std::is_trivially_destructible_v, - "Context types with custom destructors are not supported"); - - void (*start_wrapper)(Context *, void *); - if constexpr (!std::is_same_v) { - start_wrapper = [](Context *ctx_base, void *data) { - auto &ctx = *static_cast(ctx_base); - auto fn_ptr = (StartFn *)data; - - ctx.submit([fn = StartFn(*fn_ptr)](ContextT &ctx) { - fn(ctx); - }, false, ctx.currentJobID()); - }; - } else { - start_wrapper = start_fn; - start_fn = nullptr; - } - - void (*update_wrapper)(Context *, void *); - if constexpr (!std::is_same_v) { - update_wrapper = [](Context *ctx_base, void *data) { - auto &ctx = *static_cast(ctx_base); - auto fn_ptr = (UpdateFn *)data; - - ctx.submit([fn = UpdateFn(*fn_ptr)](ContextT &ctx) { - fn(ctx); - }, false, ctx.currentJobID()); - }; - } else { - update_wrapper = update_fn; - update_fn = nullptr; - } - - using DataT = typename ContextT::WorldDataT; - - return { - sizeof(DataT), - alignof(DataT), - [](void *ctx, void *data, WorkerInit &&init) { - new (ctx) ContextT((DataT *)data, std::forward(init)); - }, - sizeof(ContextT), - std::alignment_of_v, - std::forward(start_fn), - start_wrapper, - std::forward(update_fn), - update_wrapper, - }; -} - -template -JobManager::JobManager(const EntryConfig &entry_cfg, - int desired_num_workers, - int num_io, - StateManager *state_mgr, - bool pin_workers) - : JobManager(entry_cfg.numUserdataBytes, - entry_cfg.userdataAlignment, - entry_cfg.ctxInitCB, - entry_cfg.numCtxBytes, - entry_cfg.ctxAlignment, - entry_cfg.startWrapper, - [&entry_cfg]() { - if constexpr (std::is_same_v) { - (void)entry_cfg; - return nullptr; - } else { - return (void *)&entry_cfg.startFnData; - } - }(), - entry_cfg.updateLoop, - [&entry_cfg]() { - if constexpr (std::is_same_v) { - (void)entry_cfg; - return nullptr; - } else { - return (void *)&entry_cfg.updateFnData; - } - }(), - desired_num_workers, - num_io, - state_mgr, - pin_workers) -{} - -JobID JobManager::reserveProxyJobID(int thread_idx, JobID parent_id) -{ - return reserveProxyJobID(thread_idx, parent_id.id); -} - -void JobManager::relinquishProxyJobID(int thread_idx, JobID job_id) -{ - return markInvocationsFinished(thread_idx, nullptr, job_id.id, 1); -} - -bool JobManager::shouldSplitJob(RunQueue *queue) const -{ - uint32_t cur_tail = queue->tail.load_relaxed(); - uint32_t cur_correction = queue->correction.load_relaxed(); - uint32_t cur_head = queue->head.load_relaxed(); - - return isQueueEmpty(cur_head, cur_correction, cur_tail); -} - -template -void JobManager::singleInvokeEntry(Context *ctx_base, - JobContainerBase *data) -{ - ContextT &ctx = *static_cast(ctx_base); - auto container = static_cast(data); - JobManager *job_mgr = ctx.job_mgr_; - - container->fn(ctx); - - job_mgr->markInvocationsFinished(ctx.worker_idx_, data, data->id.id, 1); -} - -template -void JobManager::multiInvokeEntry(Context *ctx_base, - JobContainerBase *data, - uint64_t invocation_offset, - uint64_t num_invocations, - RunQueue *thread_queue) -{ - ContextT &ctx = *static_cast(ctx_base); - auto container = static_cast(data); - JobManager *job_mgr = ctx.job_mgr_; - - // This loop is never called with num_invocations == 0 - uint64_t invocation_idx = invocation_offset; - uint64_t remaining_invocations = num_invocations; - do { - uint64_t cur_invocation = invocation_idx++; - remaining_invocations -= 1; - - if (remaining_invocations > 0 && - job_mgr->shouldSplitJob(thread_queue)) { - job_mgr->splitJob(&multiInvokeEntry, data, - invocation_idx, remaining_invocations, thread_queue); - remaining_invocations = 0; - } - - container->fn(ctx, cur_invocation); - } while (remaining_invocations > 0); - - job_mgr->markInvocationsFinished(ctx.worker_idx_, data, data->id.id, - invocation_idx - invocation_offset); -} - -template -JobID JobManager::queueJob(int thread_idx, - Fn &&fn, - uint32_t num_invocations, - JobID parent_id, - MADRONA_MW_COND(uint32_t world_id,) - JobPriority prio, - DepTs ...deps) -{ - static constexpr uint32_t num_deps = sizeof...(DepTs); - using ContainerT = JobContainer; - static_assert(std::is_trivially_destructible_v); - -#ifdef MADRONA_GCC -#pragma GCC diagnostic push -#pragma GCC diagnostic ignored "-Winvalid-offsetof" -#endif - static_assert(num_deps == 0 || - offsetof(ContainerT, dependencies) == sizeof(JobContainerBase), - "Dependencies at incorrect offset in container type"); -#ifdef MADRONA_GCC -#pragma GCC diagnostic pop -#endif - - static constexpr uint64_t job_size = sizeof(ContainerT); - static constexpr uint64_t job_alignment = alignof(ContainerT); - static_assert(job_size <= JobManager::Alloc::maxJobSize, - "Job lambda capture is too large"); - static_assert(job_alignment <= JobManager::Alloc::maxJobAlignment, - "Job lambda capture has too large an alignment requirement"); - static_assert(utils::isPower2(job_alignment)); - - void *store = allocJob(thread_idx, job_size, job_alignment); - - auto container = new (store) ContainerT( - job_size, MADRONA_MW_COND(world_id,) std::forward(fn), deps...); - - void (*entry)(); - if constexpr (single_invoke) { - SingleInvokeFn fn_ptr = &singleInvokeEntry; - entry = (void (*)())fn_ptr; - } else { - MultiInvokeFn fn_ptr = &multiInvokeEntry; - entry = (void (*)())fn_ptr; - } - - return queueJob(thread_idx, entry, container, num_invocations, - parent_id.id, prio); -} - -void * JobManager::allocJob(int worker_idx, uint32_t num_bytes, - uint32_t alignment) -{ - return job_allocs_[worker_idx].alloc(alloc_state_, num_bytes, - alignment); -} - -void JobManager::deallocJob(int worker_idx, void *ptr, uint32_t num_bytes) -{ - job_allocs_[worker_idx].dealloc(alloc_state_, ptr, num_bytes); -} - -} diff --git a/src/core/CMakeLists.txt b/src/core/CMakeLists.txt index 29b1eefd..b0d147b3 100644 --- a/src/core/CMakeLists.txt +++ b/src/core/CMakeLists.txt @@ -1,7 +1,6 @@ set(MADRONA_CORE_SRCS ${MADRONA_INC_DIR}/fwd.hpp ${MADRONA_INC_DIR}/exec_mode.hpp - #${MADRONA_INC_DIR}/job.hpp ${MADRONA_INC_DIR}/job.inl job.cpp ${MADRONA_INC_DIR}/state.hpp ${MADRONA_INC_DIR}/state.inl state.cpp ${MADRONA_INC_DIR}/context.hpp ${MADRONA_INC_DIR}/context.inl context.cpp ${MADRONA_INC_DIR}/components.hpp base.cpp diff --git a/src/core/job.cpp b/src/core/job.cpp deleted file mode 100644 index 40df7f15..00000000 --- a/src/core/job.cpp +++ /dev/null @@ -1,1668 +0,0 @@ -/* - * Copyright 2021-2022 Brennan Shacklett and contributors - * - * Use of this source code is governed by an MIT-style - * license that can be found in the LICENSE file or at - * https://opensource.org/licenses/MIT. - */ -#include -#include -#include -#include - -#include "worker_init.hpp" - -#if defined(__linux__) or defined(__APPLE__) -#include -#include -#endif - -#include - -#if defined(MADRONA_X64) -#include -#elif defined(MADRONA_ARM) -#endif - -using std::atomic_thread_fence; - -namespace madrona { -namespace { - -struct JobTracker { - uint32_t parent; - uint32_t remainingInvocations; - uint32_t numOutstandingJobs; -}; - -template -struct JobTrackerMapStore -{ - inline T & operator[](uint32_t idx); - inline const T & operator[](uint32_t idx) const; - JobTrackerMapStore(uint32_t) {} - uint32_t expand(uint32_t) - { - FATAL("Out of job IDs\n"); - } - - static constexpr uint64_t pastMapOffset(); -}; - -using JobTrackerMap = IDMap; - -template -constexpr uint64_t JobTrackerMapStore::pastMapOffset() -{ - return sizeof(JobTrackerMap) - offsetof(JobTrackerMap, store_); -} - -template -T & JobTrackerMapStore::operator[](uint32_t idx) -{ - return ((T *)((char *)this + pastMapOffset()))[idx]; -} - -template -const T & JobTrackerMapStore::operator[](uint32_t idx) const -{ - return ((const T *)((const char *)this + pastMapOffset()))[idx]; -} - -struct LogEntry { - enum class Type : uint32_t { - JobFinished, - WaitingJobQueued, - JobCreated, - }; - - struct JobFinished { - JobContainerBase *jobData; - int32_t jobIdx; - uint32_t numCompleted; - }; - - struct WaitingJobQueued { - void (*fnPtr)(); - JobContainerBase *jobData; - uint32_t numInvocations; - }; - - struct JobCreated { - uint32_t parentID; - }; - - Type type; - union { - JobFinished finished; - WaitingJobQueued waiting; - JobCreated created; - }; -}; - -#if 0 -#include -std::vector globalLog; -void printGlobalLog() -{ - for (LogEntry &entry : globalLog) { - switch (entry.type) { - case LogEntry::Type::JobFinished: { - printf("F: (%u %u) %u %u\n", entry.finished.jobIdx.id, entry.finished.jobIdx.gen, entry.finished.numCompleted, entry.finished.threadID); - } break; - case LogEntry::Type::WaitingJobQueued: { - printf("W: (%u %u) %u\n", entry.waiting.id.id, entry.waiting.id.gen, entry.waiting.numInvocations); - } break; - case LogEntry::Type::JobCreated: { - printf("C: (%u %u) %u %u\n", entry.created.curID.id, entry.created.curID.gen, entry.created.parentID, entry.created.numInvocations); - } break; - } - } -} -#endif - -struct alignas(MADRONA_CACHE_LINE) WorkerState { - uint32_t numIdleLoops; - uint32_t numConsecutiveSchedulerCalls; - AtomicU32 logHead; // Only modified by scheduler - uint32_t logTailCache; // Scheduler's last read tail value - // logTail below is only modified by worker thread. - // Still has to be atomic for memory ordering guarantees (worker - // releases updates to log tail to guarantee the log entries themselves are - // visible after an acquire fence by the scheduler - alignas(MADRONA_CACHE_LINE) AtomicU32 logTail; - alignas(MADRONA_CACHE_LINE) AtomicU32 wakeUp; -}; - -// Plan: -// - High level goal: remove all contended atomics in job dependency system and outstanding job tracking system. -// - Centralize job system control into the "Scheduler" -// - When a worker thread can't find work, it attempts to -// wait acquire the Scheduler lock. If this fails, another -// thread is running the scheduler, so it will go to sleep -// on WorkerWakeup primitive. -// - Q: Is this going to create massive contention on the Scheduler lock, or is -// there a way to have a relaxed load of the scheduler lock be safe / accurate (double checked locking maybe) -// - WorkerWakeup primitive: futex / WaitOnAddress / __ulock_wait + spin. -// - Futex may not be strictly necessary here, since the assumption is likely that if we're about to wait, we're actually going to be put to sleep by the kernel. On OSX the only alternative seems to be posix cond vars though, which seem really slow. Regardless, the idea would be a couple of rounds of spinning (possibly using the PAUSE instruction?) where the worker makes sure it isn't just about to be woken up. -// - Q: Does futex already provide this spin? A: Possibly, but it's irrelevant. This spin should actually be continually looking for new jobs after each PAUSE. -// - Worker Logs: each worker thread gets a log. This records every job the worker has completed, including job ID and number of completed invocations. -// - Scheduler: the scheduler has 2 jobs: -// - Run through all worker logs and use this to update job dependency info, moving any jobs from wait queue to run queue that have fulfilled dependencies. (Possible optimization: workers could check job dependency info and if they've fully satisified a requirement, just immediately unblock a job in order to skip needing to go through the scheduler). -// - Wake up sleeping workers based on # of jobs that are ready for execution -// - Related change: when worker decides to split or queue immediately runnable job, it should similarly trigger worker wakeups. Solution could be to wake up 1 worker, which immediately runs and wakes up others. Want to avoid a really heavy search over all threads each time a split occurs. Futex2 / WaitForMultipleObjects are linux / windows options as well (one thread local futex + one "Wake them all" futex). -// - Pitfall: scheduler running is potentially a big synchronization point -// - Option: Scheduler early outs after finding runnable N jobs -// - Option: Some way to parallelize scheduler? -// - MW tick synchronization: -// - Scheduler runs: finds no runnable work, *and all other workers are sleeping*. At this point it does 3 things: -// - Check should_exit flag: if this is true, it wakes all other workers and exits (can imagine more efficient impls here, this will cause N threads worth of scheduler entries - but whatever).sizeof(WaitQueue) + -// - Signal external "frame done" futex. -// - Point A: Wait on external "Launch next frame" futex. Upon wakeup, rerun scheduler -// - JobManager updateLoop inserts dependency on JobID 0 rather than ctx.currentJobID() to update loop resubmission. The idea is that before signaling the launch next frame futex, external code updates JobID 0 (ez option, JobID 0 always has 1 outstanding job, just increase generation). This means after the futex is signaled and the worker at Point A runs, the "next frame" job will be ready and moved to the run queue by the scheduler. -// - Alternative: no resubmission of updateJob by itself. Instead, before signalling the "launch next frame" futex, the system manually queues updateJob requests into each worker run queue and signals all workers. -// - Issue: Entire above strategy is bugged if you have background work you want to keep going. Can not depend on workers being stopped as termination condition. Similarly, cannot guarantee the system is idle in order to slide updateJobs in manually. Need to use fake dependency strategy + an additional job dependent on ctx.currentJobID that when run increments an atomic. When that atomic reaches # worlds, we signal the "frame done" futex in order to wake up the external thread. -// - How do we handle waking up the worker threads in this model? Update the job dependency and then check if the scheduler is locked? If not, run the scheduler ourselves from the external thread? Scheduler could have it's own run queue. Then worker thread model would be: check my run queue -> check scheduler run queue -> try stealing from all other run queues. -// - This system almost certainly has a missed wakeup type race where the scheduler incorrectly concludes that no threads need to be woken up, just as other threads fail to acquire the scheduler lock and then go to sleep. Does this mean we need to ensure each worker thread acquires the scheduler before it sleeps? - -namespace consts { - constexpr uint64_t jobQueueStartAlignment = MADRONA_CACHE_LINE; - - constexpr int waitQueueSizePerWorld = 1024; - - constexpr int runQueueSizePerThread = 65536; - constexpr uint32_t runQueueIndexMask = (uint32_t)runQueueSizePerThread - 1; - - template - constexpr uint64_t computeRunQueueBytes() - { - static_assert(offsetof(JobManager::RunQueue, tail) == - jobQueueStartAlignment); - - constexpr uint64_t bytes_per_thread = - sizeof(JobManager::RunQueue) + num_jobs * sizeof(Job); - - return utils::roundUp(bytes_per_thread, jobQueueStartAlignment); - } - - constexpr uint64_t runQueueBytesPerThread = - computeRunQueueBytes(); - - constexpr int logSizePerThread = 4096; // FIXME: should decrease this and add functionality to force scheduler run - constexpr int logSizeSafetyMargin = logSizePerThread >> 3; - constexpr int logSizeMaxSafeCapacity = logSizePerThread - logSizeSafetyMargin; - constexpr uint32_t logIndexMask = (uint32_t)logSizePerThread - 1; - constexpr uint64_t logBytesPerThread = logSizePerThread * sizeof(LogEntry); - - constexpr uint32_t jobQueueSentinel = 0xFFFFFFFF; - constexpr uint32_t jobAllocSentinel = 0xFFFFFFFF; - constexpr uint32_t numJobAllocArenas = 1024; -} - -inline void workerPause() -{ -#if defined(MADRONA_X64) - _mm_pause(); -#elif defined(MADRONA_ARM) -#if defined(MADRONA_GCC) or defined(MADRONA_CLANG) - asm volatile("yield"); -#elif defined(MADRONA_MSVC) - YieldProcessor(); -#endif -#endif -} - -inline void workerYield() -{ -#if defined(__linux__) or defined(__APPLE__) - sched_yield(); -#elif defined(_WIN32) - STATIC_UNIMPLEMENTED(); -#else - STATIC_UNIMPLEMENTED(); -#endif -} - -inline uint32_t acquireArena(JobManager::Alloc::SharedState &shared) -{ - uint32_t cur_head = shared.freeHead.load_acquire(); - uint32_t new_head, arena_idx; - do { - if (cur_head == consts::jobAllocSentinel) { - FATAL("Out of job memory"); - } - - arena_idx = cur_head & 0xFFFF; - new_head = shared.arenas[arena_idx].metadata.load_relaxed(); - - // Update the tag - new_head += ((uint32_t)1u << (uint32_t)16); - } while (!shared.freeHead.compare_exchange_weak< - sync::release, sync::acquire>(cur_head, new_head)); - - // Arena metadata field is reused for counting used bytes, need to 0 out - shared.arenas[arena_idx].metadata.store_release(0); - - return arena_idx; -} - -inline void releaseArena(JobManager::Alloc::SharedState &shared, - uint32_t arena_idx) -{ - uint32_t cur_head = shared.freeHead.load_relaxed(); - uint32_t new_head; - - do { - new_head = (cur_head & 0xFFFF0000) + ((uint32_t)1u << (uint32_t)16) + arena_idx; - shared.arenas[arena_idx].metadata.store(cur_head, sync::relaxed); - } while (!shared.freeHead.compare_exchange_weak< - sync::release, sync::relaxed>(cur_head, new_head)); -} - -void disableThreadSignals() -{ -#if defined(__linux__) or defined(__APPLE__) - sigset_t mask; - sigfillset(&mask); - sigdelset(&mask, SIGSEGV); - sigdelset(&mask, SIGILL); - sigdelset(&mask, SIGBUS); - sigdelset(&mask, SIGTRAP); - sigdelset(&mask, SIGFPE); - int res = pthread_sigmask(SIG_BLOCK, &mask, nullptr); - bool failed = res != 0; -#elif defined(_WIN32) - STATIC_UNIMPLEMENTED(); -#else - STATIC_UNIMPLEMENTED(); -#endif - - if (failed) { - FATAL("failed to block signals for fiber executor"); - } -} - -int getNumWorkers(int num_workers) -{ - if (num_workers != 0) { - return num_workers; - } - -#if defined(__linux__) or defined(__APPLE__) - int os_num_threads = sysconf(_SC_NPROCESSORS_ONLN); - - if (os_num_threads == -1) { - FATAL("Failed to get number of concurrent threads"); - } - - return os_num_threads; -#elif defined(_WIN32) -#else - STATIC_UNIMPLEMENTED(); -#endif -} - -void setThreadAffinity(int thread_idx) -{ -#if defined(__linux__) - cpu_set_t cpuset; - pthread_getaffinity_np(pthread_self(), sizeof(cpuset), &cpuset); - - const int max_threads = CPU_COUNT(&cpuset); - - CPU_ZERO(&cpuset); - - if (thread_idx > max_threads) [[unlikely]] { - FATAL("Tried setting thread affinity to %d when %d is max", - thread_idx, max_threads); - } - - CPU_SET(thread_idx, &cpuset); - - int res = pthread_setaffinity_np(pthread_self(), sizeof(cpuset), &cpuset); - - if (res != 0) { - FATAL("Failed to set thread affinity to %d", thread_idx); - } -#elif defined(__APPLE__) - (void)thread_idx; - // No thread affinity on macOS / iOS :( -#elif defined(_WIN32) - STATIC_UNIMPLEMENTED(); -#else - STATIC_UNIMPLEMENTED(); -#endif -} - -// 2 phases: workers decrement numRemaining (initialized to # threads) -// and then spin until it reaches 0. Next, all workers add 1 to numAcked, -// and the main thread spins until numAcked == # threads, at which point it -// knows all threads have finished initialization. Simply waiting for -// numRemaining to be 0 is insufficient, because worker threads may still -// be spinning, waiting to see that numRemaining is 0, when the ThreadPoolInit -// struct is freed -struct ThreadPoolInit { - AtomicI32 numRemaining; - AtomicI32 numAcked; - - inline void workerWait() - { - numRemaining.fetch_sub_release(1); - - while (numRemaining.load_acquire() != 0) { - workerYield(); - } - - numAcked.fetch_add_release(1); - } - - inline void mainWait(int num_threads) - { - while (numAcked.load_acquire() != num_threads) { - workerYield(); - } - } -}; - -inline WorkerState & getWorkerState(void *base, int thread_idx) -{ - return ((WorkerState *)base)[thread_idx]; -} - -inline LogEntry * getWorkerLog(void *base, int thread_idx) -{ - return (LogEntry *)((char *)base + consts::logBytesPerThread * thread_idx); -} - -inline JobManager::RunQueue * getRunQueue( - void *queue_base, const int thread_idx) -{ - return (JobManager::RunQueue *)((char *)queue_base + - thread_idx * consts::runQueueBytesPerThread); -} - -inline Job * getRunnableJobs(JobManager::RunQueue *queue) -{ - return (Job *)((char *)queue + sizeof(JobManager::RunQueue)); -} - -inline JobTrackerMap & getTrackerMap(void *base) -{ - return *(JobTrackerMap *)base; -} - -inline JobTrackerMap::Cache & getTrackerCache(void *tracker_cache_base, - int thread_idx) -{ - return ((JobTrackerMap::Cache *)tracker_cache_base)[thread_idx]; -} - -inline void decrementJobTracker(JobTrackerMap &tracker_map, - JobTrackerMap::Cache &tracker_cache, - int32_t job_id) -{ - - while (job_id != JobID::none().id) { - JobTracker &tracker = tracker_map.getRef(job_id); - - uint32_t num_outstanding = --tracker.numOutstandingJobs; - - if (num_outstanding == 0 && tracker.remainingInvocations == 0) { - uint32_t parent = tracker.parent; - - tracker_map.releaseID(tracker_cache, job_id); -#ifdef TSAN_ENABLED - tracker_map.releaseGen(job_id); -#endif - job_id = parent; - } else { - break; - } - } -} - -inline const JobID * getJobDependencies(JobContainerBase *job_base) -{ - return (const JobID *)((char *)job_base + sizeof(JobContainerBase)); -} - -inline bool isRunnable(JobTrackerMap &tracker_map, - JobContainerBase *job_data) -{ - int num_deps = job_data->numDependencies; - - if (num_deps == 0) { - return true; - } - - const JobID *dependencies = getJobDependencies(job_data); - for (int i = 0; i < num_deps; i++) { - JobID dependency = dependencies[i]; - - if (tracker_map.present(dependency)) { - return false; - } - } - - return true; -} - -template -inline uint32_t addToRunQueueImpl(JobManager::RunQueue *run_queue, - Fn &&add_cb) -{ - // No one modifies queue_tail besides this thread - uint32_t cur_tail = run_queue->tail.load_relaxed(); - Job *job_array = getRunnableJobs(run_queue); - - uint32_t num_added = add_cb(job_array, cur_tail); - - cur_tail += num_added; - run_queue->tail.store_release(cur_tail); - - return num_added; -} - -inline void addToLog(WorkerState &worker_state, LogEntry *worker_log, - const LogEntry &entry) -{ - uint32_t cur_tail = worker_state.logTail.load_relaxed(); - uint32_t new_idx = cur_tail & consts::logIndexMask; - - worker_log[new_idx] = entry; - - uint32_t new_tail = cur_tail + 1; - worker_state.logTail.store_release(new_tail); - - uint32_t log_head = worker_state.logHead.load_relaxed(); - if (new_tail - log_head >= consts::logSizePerThread) [[unlikely]] { - for (uint32_t i = log_head; i != new_tail; i++) { - LogEntry &debug_entry = worker_log[i & consts::logIndexMask]; - switch (debug_entry.type) { - case LogEntry::Type::JobFinished: { - printf("Job finished\n"); - } break; - case LogEntry::Type::WaitingJobQueued: { - printf("Waiting job queued\n"); - } break; - case LogEntry::Type::JobCreated: { - printf("JobCreated\n"); - } break; - } - } - FATAL("Worker filled up job system log"); - } -} - -} - -JobManager::Alloc::Alloc(SharedState &shared) - : cur_arena_(acquireArena(shared)), - next_arena_(acquireArena(shared)), - arena_offset_(0), - arena_used_bytes_(0) -{} - -void * JobManager::Alloc::alloc(SharedState &shared, - uint32_t num_bytes, - uint32_t alignment) -{ - // Get offset necessary to meet alignment requirements. - // Alignment must be less than maxJobAlignment (otherwise base address not - // guaranteed to meet alignment). - uint32_t new_offset = utils::roundUpPow2(arena_offset_, alignment); - - if (new_offset + num_bytes <= arena_size_) { - arena_offset_ = new_offset; - } else { - // Out of space in this arena, mark this arena as freeable - // and get a new one - - // Marking the arena as freeable just involves adding the total memory - // used in the arena to the arena's metadata value. Once all jobs in - // the arena have been freed these values will cancel out and the - // metadata value will be zero. - uint32_t post_metadata = - shared.arenas[cur_arena_].metadata.fetch_add_acq_rel( - arena_used_bytes_); - post_metadata += arena_used_bytes_; - - // Edge case, if post_metadata == 0, we can skip getting a new arena - // because there are no active jobs left in the current arena, so - // the cur_arena_ can immediately be reused by resetting offsets to 0 - if (post_metadata != 0) { - // Get next free arena. First check the cached arena in next_arena_ - if (next_arena_ != consts::jobAllocSentinel) { - cur_arena_ = next_arena_; - next_arena_ = consts::jobAllocSentinel; - } else { - cur_arena_ = acquireArena(shared); - } - } - - arena_offset_ = 0; - arena_used_bytes_ = 0; - } - - void *mem = - (char *)shared.jobMemory + arena_size_ * cur_arena_ + arena_offset_; - - arena_offset_ += num_bytes; - - // Need to track arena_used_bytes_ separately from arena_offset_, - // because deallocation code doesn't know how many extra bytes get added - // to each job for alignment padding reasons. - arena_used_bytes_ += num_bytes; - - return mem; -} - -void JobManager::Alloc::dealloc(SharedState &shared, - void *ptr, uint32_t num_bytes) -{ - size_t ptr_offset = (char *)ptr - (char *)shared.jobMemory; - uint32_t arena_idx = ptr_offset / arena_size_; - - Arena &arena = shared.arenas[arena_idx]; - - uint32_t post_metadata = arena.metadata.fetch_sub_acq_rel(num_bytes); - post_metadata -= num_bytes; - - if (post_metadata == 0) { - // If this thread doesn't have a cached free arena, store there, - // otherwise release to global free list - if (next_arena_ == consts::jobAllocSentinel) { - next_arena_ = arena_idx; - } else { - releaseArena(shared, arena_idx); - } - } -} - -JobManager::Alloc::SharedState JobManager::Alloc::makeSharedState( - InitAlloc alloc, uint32_t num_arenas) -{ - if (num_arenas > 65536) { - FATAL("Job allocator can only support up to 2^16 arenas."); - } - - uint64_t total_bytes = (maxJobAlignment - 1) + num_arenas * arena_size_ + - num_arenas * sizeof(Arena); - - void *mem = alloc.alloc(total_bytes); - - void *job_mem = - (void *)utils::roundUp((uintptr_t)mem, (uintptr_t)maxJobAlignment); - - Arena *arenas = (Arena *)((char *)job_mem + arena_size_ * num_arenas); - - // Build initial linear freelist - for (int i = 0; i < (int)num_arenas; i++) { - new (&arenas[i]) Arena { - (i < int(num_arenas - 1)) ? i + 1 : consts::jobQueueSentinel, - }; - } - - return SharedState { - mem, - job_mem, - arenas, - 0, - }; -} - -struct JobManager::Init { - uint32_t numCtxUserdataBytes; - void (*ctxInitFn)(void *, void *, WorkerInit &&); - uint32_t numCtxBytes; - void (*startFn)(Context *, void *); - void *startFnData; - void (*updateFn)(Context *, void *); - void *updateFnData; - int numWorkers; - int numIO; - int numThreads; - StateManager *stateMgr; - bool pinWorkers; - void *statePtr; - void *ctxBase; - void *ctxUserdataBase; - void *stateCacheBase; - void *highBase; - void *normalBase; - void *ioBase; - void *workerStateBase; - void *logBase; - void *waitingJobs; - void *trackerBase; - void *trackerCacheBase; - int numTrackerSlots; -}; - -JobManager::JobManager(uint32_t num_ctx_userdata_bytes, - uint32_t ctx_userdata_alignment, - void (*ctx_init_fn)(void *, void *, WorkerInit &&), - uint32_t num_ctx_bytes, - uint32_t ctx_alignment, - void (*start_fn)(Context *, void *), - void *start_fn_data, - void (*update_fn)(Context *, void *), - void *update_fn_data, - int desired_num_workers, - int num_io, - StateManager *state_mgr, - bool pin_workers) - : JobManager([num_ctx_userdata_bytes, - ctx_userdata_alignment, ctx_init_fn, - num_ctx_bytes, ctx_alignment, - start_fn, start_fn_data, update_fn, update_fn_data, - desired_num_workers, num_io, state_mgr, pin_workers]() { - int num_workers = getNumWorkers(desired_num_workers); - int num_threads = num_workers + num_io; - - uint64_t num_state_bytes = 0; - - uint64_t total_ctx_bytes = - (uint64_t)num_threads * (uint64_t)num_ctx_bytes; - uint64_t total_userdata_bytes = num_ctx_userdata_bytes; -#ifdef MADRONA_MW_MODE - uint64_t num_worlds = state_mgr->numWorlds(); - - total_ctx_bytes *= num_worlds; - total_userdata_bytes *= num_worlds; -#else - uint64_t num_worlds = 1; -#endif - - uint64_t ctx_offset = 0; - num_state_bytes = ctx_offset + total_ctx_bytes; - - uint64_t ctx_userdata_offset = utils::roundUp(num_state_bytes, - (uint64_t)ctx_userdata_alignment); - - num_state_bytes = ctx_userdata_offset + total_userdata_bytes; - - uint64_t state_cache_offset = utils::roundUp(num_state_bytes, - (uint64_t)alignof(StateCache)); - - num_state_bytes = - state_cache_offset + sizeof(StateCache) * num_threads; - - uint64_t high_offset = - utils::roundUp(num_state_bytes, consts::jobQueueStartAlignment); - num_state_bytes = - high_offset + num_threads * consts::runQueueBytesPerThread; - - uint64_t normal_offset = - utils::roundUp(num_state_bytes, consts::jobQueueStartAlignment); - num_state_bytes = - normal_offset + num_threads * consts::runQueueBytesPerThread; - - uint64_t io_offset = - utils::roundUp(num_state_bytes, consts::jobQueueStartAlignment); - num_state_bytes = - io_offset + num_threads * consts::runQueueBytesPerThread; - - uint64_t worker_state_offset = - utils::roundUp(num_state_bytes, (uint64_t)alignof(WorkerState)); - num_state_bytes = - worker_state_offset + num_threads * sizeof(WorkerState); - - uint64_t log_offset = - utils::roundUp(num_state_bytes, consts::jobQueueStartAlignment); - num_state_bytes = log_offset + - num_threads * consts::logSizePerThread * sizeof(LogEntry); - - uint64_t wait_offset = - utils::roundUp(num_state_bytes, consts::jobQueueStartAlignment); - num_state_bytes = wait_offset + - num_worlds * consts::waitQueueSizePerWorld * sizeof(Job); - - uint64_t tracker_cache_offset = - utils::roundUp(num_state_bytes, (uint64_t)alignof(JobTrackerMap::Cache)); - - num_state_bytes = - tracker_cache_offset + num_threads * sizeof(JobTrackerMap::Cache); - - int num_tracker_slots = num_threads * ( - consts::logSizePerThread + consts::runQueueSizePerThread); - - uint64_t tracker_offset = - utils::roundUp(num_state_bytes, consts::jobQueueStartAlignment); - - static_assert( - sizeof(JobTrackerMap) % alignof(JobTrackerMap::Node) == 0); - - num_state_bytes = tracker_offset + sizeof(JobTrackerMap) + - num_tracker_slots * sizeof(JobTrackerMap::Node); - - // Add padding so the base pointer can be aligned - num_state_bytes += ctx_alignment - 1; - - void *state_ptr = InitAlloc().alloc(num_state_bytes); - - char *base_ptr = (char *)utils::alignPtr(state_ptr, ctx_alignment); - - return Init { - .numCtxUserdataBytes = num_ctx_userdata_bytes, - .ctxInitFn = ctx_init_fn, - .numCtxBytes = num_ctx_bytes, - .startFn = start_fn, - .startFnData = start_fn_data, - .updateFn = update_fn, - .updateFnData = update_fn_data, - .numWorkers = num_workers, - .numIO = num_io, - .numThreads = num_threads, - .stateMgr = state_mgr, - .pinWorkers = pin_workers, - .statePtr = state_ptr, - .ctxBase = base_ptr + ctx_offset, - .ctxUserdataBase = base_ptr + ctx_userdata_offset, - .stateCacheBase = base_ptr + state_cache_offset, - .highBase = base_ptr + high_offset, - .normalBase = base_ptr + normal_offset, - .ioBase = base_ptr + io_offset, - .workerStateBase = base_ptr + worker_state_offset, - .logBase = base_ptr + log_offset, - .waitingJobs = base_ptr + wait_offset, - .trackerBase = base_ptr + tracker_offset, - .trackerCacheBase = base_ptr + tracker_cache_offset, - .numTrackerSlots = num_tracker_slots, - }; - }()) -{} - -JobManager::JobManager(const Init &init) - : threads_(init.numThreads, InitAlloc()), - alloc_state_(Alloc::makeSharedState(InitAlloc(), - consts::numJobAllocArenas)), - job_allocs_(threads_.size(), InitAlloc()), - scheduler_ { - .numWaiting = 0, - .numSleepingWorkers = 0, - .lock {}, - }, - state_ptr_(init.statePtr), - high_base_(init.highBase), - normal_base_(init.normalBase), - io_base_(init.ioBase), - tracker_base_(init.trackerBase), - tracker_cache_base_(init.trackerCacheBase), - worker_base_(init.workerStateBase), - log_base_(init.logBase), - waiting_jobs_(init.waitingJobs), - num_compute_workers_(init.numWorkers), - io_sema_(0), - num_high_(0) -{ - for (int i = 0, n = init.numThreads; i < n; i++) { - job_allocs_.emplace(i, alloc_state_); - } - - auto initQueue = [](void *queue_start, int thread_idx) { - RunQueue *queue = getRunQueue(queue_start, thread_idx); - - new (queue) RunQueue { - .head = 0, - .correction = 0, - .auth = 0, - .pad = {}, - .tail = 0, - }; - }; - - JobTrackerMap &tracker_map = getTrackerMap(tracker_base_); - new (&tracker_map) JobTrackerMap(init.numTrackerSlots); - - // Setup per-thread state and queues - for (int i = 0, n = threads_.size(); i < n; i++) { - initQueue(normal_base_, i); - initQueue(high_base_, i); - initQueue(io_base_, i); - - WorkerState &worker_state = getWorkerState(worker_base_, i); - new (&worker_state) WorkerState { - .numIdleLoops = 0, - .numConsecutiveSchedulerCalls = 0, - .logHead = 0, - .logTailCache = 0, - .logTail = 0, - .wakeUp = i + 1, - }; - - JobTrackerMap::Cache &cache = getTrackerCache(tracker_cache_base_, i); - new (&cache) JobTrackerMap::Cache(); - } - - struct StartWrapper { - void (*func)(Context *, void *); - void *data; - AtomicU32 remainingLaunches; - } start_wrapper { - init.startFn, - init.startFnData, -#ifdef MADRONA_MW_MODE - init.stateMgr->numWorlds(), -#else - 1, -#endif - }; - - struct StartJob : JobContainerBase { - StartWrapper *wrapper; - }; - - SingleInvokeFn entry = [](Context *ctx, JobContainerBase *ptr) { - auto &job = *(StartJob *)ptr; - auto &start = *(job.wrapper); - - start.func(ctx, start.data); - - uint32_t job_id = ptr->id.id; - start.remainingLaunches.fetch_sub_release(1); - - ctx->job_mgr_->markInvocationsFinished(ctx->worker_idx_, nullptr, - job_id, 1); - }; - - // Initial job - -#ifdef MADRONA_MW_MODE - int num_worlds = init.stateMgr->numWorlds(); - - HeapArray start_jobs(num_worlds); - - for (int i = 0; i < num_worlds; i++) { - start_jobs[i] = StartJob { - JobContainerBase { JobID::none(), sizeof(StartJob), (uint32_t)i, - 0 }, - &start_wrapper, - }; - - queueJob(i % init.numWorkers, (void (*)())entry, &start_jobs[i], 0, - JobID::none().id, JobPriority::Normal); - } -#else - StartJob start_job { - JobContainerBase { JobID::none(), sizeof(StartJob), 0 }, - &start_wrapper, - }; - - queueJob(0, (void (*)())entry, &start_job, 0, JobID::none().id, - JobPriority::Normal); -#endif - - ThreadPoolInit pool_init { init.numThreads, 0 }; - - for (int thread_idx = 0; thread_idx < init.numThreads; thread_idx++) { - // Find the proper state cache for this thread and initialize it before - // passing to context - StateCache *thread_state_cache = (StateCache *)( - (char *)init.stateCacheBase + thread_idx * sizeof(StateCache)); - new (thread_state_cache) StateCache(); - -#ifdef MADRONA_MW_MODE - void *ctx_store = (char *)init.ctxBase + (uint64_t)thread_idx * - (uint64_t)init.numCtxBytes * (uint64_t)num_worlds; - - for (int world_idx = 0; world_idx < num_worlds; world_idx++) { - void *cur_ctx = - (char *)ctx_store + world_idx * (uint64_t)init.numCtxBytes; - - void *cur_userdata = (char *)init.ctxUserdataBase + - world_idx * (uint64_t)init.numCtxUserdataBytes; - - init.ctxInitFn(cur_ctx, cur_userdata, WorkerInit { - .jobMgr = this, - .stateMgr = init.stateMgr, - .stateCache = thread_state_cache, - .workerIdx = thread_idx, - .worldID = (uint32_t)world_idx, - }); - } -#else - void *ctx_store = (char *)init.ctxBase + thread_idx * init.numCtxBytes; - init.ctxInitFn(ctx_store, init.ctxUserdataBase, WorkerInit { - .jobMgr = this, - .stateMgr = init.stateMgr, - .stateCache = thread_state_cache, - .workerIdx = thread_idx, - }); -#endif - threads_.emplace(thread_idx, [this]( - int thread_idx, - void *context_base, - uint32_t num_context_bytes, - int num_workers, - bool pin_workers, - ThreadPoolInit *pool_init) { - bool is_worker = thread_idx < num_workers; - - if (is_worker) { - disableThreadSignals(); - if (pin_workers) { - setThreadAffinity(thread_idx); - } - } - - pool_init->workerWait(); - - if (is_worker) { - workerThread(thread_idx, context_base, - num_context_bytes); - } else { - ioThread(thread_idx, context_base, - num_context_bytes); - } - }, thread_idx, ctx_store, init.numCtxBytes, init.numWorkers, - init.pinWorkers, &pool_init); - } - - pool_init.mainWait(init.numThreads); - - // Need to ensure start job has run at this point. - // Otherwise, the start function data can be freed / go out of scope - // before the job actually runs. - while (start_wrapper.remainingLaunches.load_acquire() != 0) { - workerYield(); - } -} - -JobManager::~JobManager() -{ - InitAlloc().dealloc(alloc_state_.memoryBase); - - InitAlloc().dealloc(state_ptr_); -} - -JobID JobManager::getNewJobID(int thread_idx, - uint32_t parent_job_idx, - uint32_t num_invocations) -{ - JobTrackerMap &tracker_map = getTrackerMap(tracker_base_); - JobTrackerMap::Cache &tracker_cache = - getTrackerCache(tracker_cache_base_, thread_idx); - WorkerState &worker_state = getWorkerState(worker_base_, thread_idx); - LogEntry *log = getWorkerLog(log_base_, thread_idx); - JobID new_id = tracker_map.acquireID(tracker_cache); - - JobTracker &tracker = tracker_map.getRef(new_id.id); - tracker.parent = parent_job_idx; - tracker.remainingInvocations = num_invocations; - tracker.numOutstandingJobs = 1; - - addToLog(worker_state, log, LogEntry { - .type = LogEntry::Type::JobCreated, - .created = { - .parentID = parent_job_idx, - }, - }); - - return new_id; -} - -JobID JobManager::queueJob(int thread_idx, - void (*job_func)(), - JobContainerBase *job_data, - uint32_t num_invocations, - uint32_t parent_job_idx, - JobPriority prio) -{ - JobTrackerMap &tracker_map = getTrackerMap(tracker_base_); - // num_invocations can be passed in as 0 here to signify a single - // invocation job, but for the purposes of dependency tracking it - // counts as a single invocation - JobID id = getNewJobID(thread_idx, parent_job_idx, std::max(num_invocations, 1u)); - - job_data->id = id; - - if (isRunnable(tracker_map, job_data)) { - atomic_thread_fence(sync::acquire); -#ifdef TSAN_ENABLED - { - const JobID *dependencies = getJobDependencies(job_data); - uint32_t num_dependencies = job_data->numDependencies; - for (int i = 0; i < (int)num_dependencies; i++) { - tracker_map.acquireGen(dependencies[i].id); - } - } -#endif - addToRunQueue(thread_idx, prio, - [=](Job *job_array, uint32_t cur_tail) { - job_array[cur_tail & consts::runQueueIndexMask] = Job { - .func = job_func, - .data = job_data, - .invocationOffset = 0, - .numInvocations = num_invocations, - }; - - return 1u; - }); - } else { - addToWaitQueue(thread_idx, job_func, job_data, num_invocations, - prio); - } - - return id; -} - -JobID JobManager::reserveProxyJobID(int thread_idx, uint32_t parent_job_idx) -{ - return getNewJobID(thread_idx, parent_job_idx, 1); -} - -void JobManager::markInvocationsFinished(int thread_idx, - JobContainerBase *job_data, - int32_t job_idx, - uint32_t num_invocations) -{ - WorkerState &worker_state = getWorkerState(worker_base_, thread_idx); - LogEntry *log = getWorkerLog(log_base_, thread_idx); - - addToLog(worker_state, log, LogEntry { - .type = LogEntry::Type::JobFinished, - .finished = { - .jobData = job_data, - .jobIdx = job_idx, - .numCompleted = num_invocations, - }, - }); -} - -template -void JobManager::addToRunQueue(int thread_idx, - JobPriority prio, - Fn &&add_cb) -{ - RunQueue *queue; - if (prio == JobPriority::High) { - queue = getRunQueue(high_base_, thread_idx); - } else if (prio == JobPriority::Normal) { - queue = getRunQueue(normal_base_, thread_idx); - } else { - queue = getRunQueue(io_base_, thread_idx); - } - - uint32_t num_added = addToRunQueueImpl(queue, std::forward(add_cb)); - - if (prio == JobPriority::High) { - num_high_.fetch_add_relaxed(num_added); - } - if (prio == JobPriority::IO) { - io_sema_.release(num_added); - } -} - -void JobManager::addToWaitQueue(int thread_idx, - void (*job_func)(), - JobContainerBase *job_data, - uint32_t num_invocations, - JobPriority prio) -{ - // FIXME Priority is dropped on jobs that need to wait - (void)prio; - - WorkerState &worker_state = getWorkerState(worker_base_, thread_idx); - LogEntry *log = getWorkerLog(log_base_, thread_idx); - - addToLog(worker_state, log, LogEntry { - .type = LogEntry::Type::WaitingJobQueued, - .waiting = { - job_func, - job_data, - num_invocations, - }, - }); -} - -#if 0 -JobID JobManager::queueJobs(int thread_idx, const Job *jobs, uint32_t num_jobs, - const JobID *deps, uint32_t num_dependencies, - JobPriority prio) -{ - (void)deps; - (void)num_dependencies; - - JobQueueTail *queue_tail; - if (prio == JobPriority::High) { - queue_tail = getQueueTail(getQueueHead(high_base_, thread_idx)); - } else if (prio == JobPriority::Normal) { - queue_tail = getQueueTail(getQueueHead(normal_base_, thread_idx)); - } else { - queue_tail = getQueueTail(getQueueHead(io_base_, thread_idx)); - } - - AtomicU32 &tail = queue_tail->tail; - - // No one modifies queue_tail besides this thread - uint32_t cur_tail = tail.load(sync::relaxed); - uint32_t wrapped_idx = (cur_tail & consts::jobQueueIndexMask); - - Job *job_array = getRunnableJobs(queue_tail); - - uint32_t num_remaining = consts::jobQueueSizePerThread - wrapped_idx; - uint32_t num_fit = std::min(num_remaining, num_jobs); - memcpy(job_array + wrapped_idx, jobs, num_fit * sizeof(Job)); - - if (num_remaining < num_jobs) { - uint32_t num_wrapped = num_jobs - num_remaining; - memcpy(job_array, jobs + num_remaining, num_wrapped * sizeof(Job)); - } - - cur_tail += num_jobs; - tail.store(cur_tail, sync::relaxed); - - if (prio == JobPriority::High) { - num_high_.fetch_add(num_jobs, sync::relaxed); - } - if (prio == JobPriority::IO) { - io_sema_.release(num_jobs); - } - - num_outstanding_.fetch_add(num_jobs, sync::relaxed); - - atomic_thread_fence(sync::release); - - return JobID(0); -} -#endif - -enum class JobManager::WorkerControl : uint64_t { - Run, - LoopIdle, - LoopBusy, - Sleep, - Exit, -}; - -JobManager::WorkerControl JobManager::schedule(int thread_idx, Job *run_job) -{ - JobTrackerMap &tracker_map = getTrackerMap(tracker_base_); - JobTrackerMap::Cache &tracker_cache = - getTrackerCache(tracker_cache_base_, thread_idx); - WorkerState &scheduling_worker = - getWorkerState(worker_base_, thread_idx); - scheduling_worker.numConsecutiveSchedulerCalls++; - - Job *waiting_jobs = (Job *)waiting_jobs_; - CountT cur_num_waiting = CountT(scheduler_.numWaiting); - - auto handleJobFinished = [&](const LogEntry::JobFinished &finished) { - JobTracker &tracker = - tracker_map.getRef(finished.jobIdx); - uint32_t remaining = tracker.remainingInvocations; - remaining -= finished.numCompleted; - tracker.remainingInvocations = remaining; - - if (remaining == 0) { - if (finished.jobData != nullptr) { - deallocJob(thread_idx, finished.jobData, - finished.jobData->jobSize); - } - - decrementJobTracker(tracker_map, tracker_cache, - finished.jobIdx); - } - }; - - auto handleWaitingQueued = [&](const LogEntry::WaitingJobQueued &waiting) { - waiting_jobs[cur_num_waiting++] = Job { - waiting.fnPtr, - waiting.jobData, - 0, - waiting.numInvocations, - }; - }; - - auto handleJobCreated = [&](const LogEntry::JobCreated &created) { - uint32_t parent_id = created.parentID; - if (parent_id != ~0u) { - JobTracker &parent_tracker = tracker_map.getRef(parent_id); - parent_tracker.numOutstandingJobs++; - } - }; - - // First, read all the log tails and cache them. This allows us to do - // a single acquire release barrier (dmb on arm) to ensure that log entries - // are consistent with job tails, as well as to release JobTracker - // generation updates in bulk. - - for (int64_t i = 0, n = threads_.size(); i < n; i++) { - WorkerState &worker_state = getWorkerState(worker_base_, i); - worker_state.logTailCache = worker_state.logTail.load_relaxed(); - TSAN_ACQUIRE(&worker_state.logTail); - } - - // Release half synchronizes all the releaseID calls under handleJobFinished - // to ensure that when isRunnable is called outside the scheduler, the - // job skipping the waitlist is synchronized-with the thread that finished - // the dependencies. - atomic_thread_fence(sync::acq_rel); - - // First, we read all the logs. - for (int64_t i = 0, n = threads_.size(); i != n; i++) { - int64_t offset = i + thread_idx; - int64_t worker_idx = offset < n ? offset : offset - n; - WorkerState &worker_state = getWorkerState(worker_base_, worker_idx); - LogEntry *log = getWorkerLog(log_base_, worker_idx); - - uint32_t log_tail = worker_state.logTailCache; - uint32_t log_head = worker_state.logHead.load_relaxed(); - - for (; log_head != log_tail; log_head++) { - LogEntry &entry = log[log_head & consts::logIndexMask]; - - switch (entry.type) { - case LogEntry::Type::JobFinished: { - handleJobFinished(entry.finished); - } break; - case LogEntry::Type::WaitingJobQueued: { - handleWaitingQueued(entry.waiting); - } break; - case LogEntry::Type::JobCreated: { - handleJobCreated(entry.created); - } break; - } - } - - worker_state.logHead.store(log_head, sync::relaxed); - } - - // Move all now runnable jobs to the scheduler's global run queue - - RunQueue *sched_run = getRunQueue(normal_base_, thread_idx); - - Job *sched_run_jobs = getRunnableJobs(sched_run); - uint32_t cur_run_tail = sched_run->tail.load_relaxed(); - int64_t num_new_invocations = 0; - int64_t compaction_offset = 0; - - bool first_found_job = true; - for (int64_t i = 0; i < cur_num_waiting; i++) { - Job &job = waiting_jobs[i]; - if (isRunnable(tracker_map, job.data)) { - uint32_t num_invocations = job.numInvocations; - - // num_invocations == 0 is a special case that indicates a one-off - // submission as opposed to a parallel for / multi invocation - // submission. For the scheduler's purpose, this counts as one - // invocation regardless - num_new_invocations += num_invocations > 0 ? num_invocations : 1; - - if (first_found_job) { - *run_job = job; - first_found_job = false; - } else { - sched_run_jobs[cur_run_tail & consts::runQueueIndexMask] = job; - cur_run_tail++; - } - } else { - int64_t cur_compaction_offset = compaction_offset++; - if (i != cur_compaction_offset) { - waiting_jobs[cur_compaction_offset] = job; - } - } - } - scheduler_.numWaiting = compaction_offset; - - if (num_new_invocations == 0) { - uint32_t sched_run_auth = sched_run->auth.load_relaxed(); - - if (sched_run_auth == cur_run_tail) { - if (scheduling_worker.numConsecutiveSchedulerCalls > 1) { - if (scheduling_worker.wakeUp.load_relaxed() != 0) { - scheduler_.numSleepingWorkers++; - } - - if (scheduler_.numWaiting == 0 && - scheduler_.numSleepingWorkers == num_compute_workers_) { - for (int64_t i = 0; i < num_compute_workers_; i++) { - WorkerState &worker_state = - getWorkerState(worker_base_, i); - worker_state.wakeUp.store(~0_u32, - sync::relaxed); - worker_state.wakeUp.notify_one(); - } - return WorkerControl::Exit; - } else { - getWorkerState(worker_base_, thread_idx) - .wakeUp.store(0, sync::relaxed); - return WorkerControl::Sleep; - } - } else { - return WorkerControl::LoopIdle; - } - } else { - return WorkerControl::LoopBusy; - } - } - - sched_run->tail.store_release(cur_run_tail); - - // Wake up compute workers based on # of jobs - int64_t num_compute_workers = num_compute_workers_; - int64_t num_wakeup = std::min(num_compute_workers, num_new_invocations); - - for (int64_t i = 0; num_wakeup > 0 && i < num_compute_workers; i++) { - WorkerState &worker_state = getWorkerState(worker_base_, i); - - if (worker_state.wakeUp.load_relaxed() == 0) { - worker_state.wakeUp.store_relaxed((uint32_t)thread_idx + 1); - worker_state.wakeUp.notify_one(); - - num_wakeup--; - scheduler_.numSleepingWorkers--; - } - } - - return WorkerControl::Run; -} - -uint32_t JobManager::dequeueJobIndex(RunQueue *job_queue) -{ - AtomicU32 &head = job_queue->head; - AtomicU32 &correction = job_queue->correction; - AtomicU32 &auth = job_queue->auth; - AtomicU32 &tail = job_queue->tail; - - uint32_t cur_tail = tail.load_relaxed(); - uint32_t cur_correction = correction.load_relaxed(); - uint32_t cur_head = head.load_relaxed(); - - if (isQueueEmpty(cur_head, cur_correction, cur_tail)) { - return consts::jobQueueSentinel; - } - - atomic_thread_fence(sync::acquire); - TSAN_ACQUIRE(&tail); - TSAN_ACQUIRE(&correction); - TSAN_ACQUIRE(&head); - - cur_head = head.fetch_add_relaxed(1); - cur_tail = tail.load_acquire(); - - if (isQueueEmpty(cur_head, cur_correction, cur_tail)) [[unlikely]] { - correction.fetch_add_release(1); - return consts::jobQueueSentinel; - } - - // Note, there is some non intuitive behavior here, where the value of idx - // can seem to be past cur_tail above. This isn't a case where too many - // items have been dequeued, instead, the producer has added another item - // to the queue and another consumer thread has come in and dequeued - // the item this thread was planning on dequeuing, so this thread picks - // up the later item. If tail is re-read after the fetch add below, - // everything would appear consistent. - return auth.fetch_add_acq_rel(1); -} - -JobManager::WorkerControl JobManager::tryScheduling( - JobManager::WorkerControl default_ctrl, int thread_idx, Job *next_job) { - if (scheduler_.lock.tryLock()) { - default_ctrl = schedule(thread_idx, next_job); - scheduler_.lock.unlock(); - } - return default_ctrl; -} - -JobManager::WorkerControl JobManager::getNextJob(void *const queue_base, - int thread_idx, - int init_search_idx, - bool run_scheduler, - Job *next_job) -{ - WorkerControl sched_ctrl = WorkerControl::LoopIdle; - - WorkerState &worker_state = getWorkerState(worker_base_, thread_idx); - uint32_t cur_tail = worker_state.logTail.load_relaxed(); - uint32_t log_head = worker_state.logHead.load_relaxed(); - // Determine if log capacity is too high (and we should try scheduling). - if (cur_tail - log_head > consts::logSizeMaxSafeCapacity) { - return tryScheduling(WorkerControl::LoopBusy, thread_idx, next_job); - } - - // First, check the current thread's queue - RunQueue *queue = getRunQueue(queue_base, init_search_idx); - uint32_t job_idx = dequeueJobIndex(queue); - - if (run_scheduler && job_idx == consts::jobQueueSentinel) { - sched_ctrl = - tryScheduling(WorkerControl::LoopIdle, thread_idx, next_job); - if (sched_ctrl != WorkerControl::LoopIdle) { - return sched_ctrl; - } - } - - // Try work stealing - if (job_idx == consts::jobQueueSentinel) { - int64_t num_queues = threads_.size(); - for (int64_t i = 1; i < num_queues; i++) { - int64_t unwrapped_idx = i + thread_idx; - int64_t queue_idx = unwrapped_idx < num_queues ? - unwrapped_idx : unwrapped_idx - num_queues; - - queue = getRunQueue(queue_base, queue_idx); - - job_idx = dequeueJobIndex(queue); - if (job_idx != consts::jobQueueSentinel) { - break; - } - } - } - - if (job_idx == consts::jobQueueSentinel) { - return WorkerControl::LoopIdle; - } - - *next_job = getRunnableJobs(queue)[job_idx & consts::runQueueIndexMask]; - - // There's no protection to prevent queueJob overwriting next_job - // in between job_idx being assigned and the job actually being - // read. If this happens it is a bug where way too many jobs are - // being created, or jobs are being processed too slowly, so we - // detect and crash with a fatal error (rather than silently - // dropping or reading corrupted jobs). - - uint32_t post_read_tail = queue->tail.load_acquire(); - - if (post_read_tail - job_idx > consts::runQueueSizePerThread) [[unlikely]] { - // Note, this is not ideal because it doesn't detect the source - // of the issue. The tradeoff is that we skip needing to read - // the head information when queueing jobs, whereas this - // code already has to read the tail once before. - FATAL("Job queue has overwritten readers. Detected by thread %d.\n" - "Job: %u, Tail: %u, Difference: %u, Queue: %p\n", - thread_idx, job_idx, post_read_tail, post_read_tail - job_idx, - queue); - } - - return WorkerControl::Run; -} - -void JobManager::splitJob(MultiInvokeFn fn_ptr, JobContainerBase *job_data, - uint32_t invocation_offset, uint32_t num_invocations, - RunQueue *run_queue) -{ - void (*generic_fn)() = (void (*)())fn_ptr; - if (num_invocations == 1) { - addToRunQueueImpl(run_queue, - [=](Job *job_array, uint32_t cur_tail) { - job_array[cur_tail & consts::runQueueIndexMask] = Job { - .func = generic_fn, - .data = job_data, - .invocationOffset = invocation_offset, - .numInvocations = 1, - }; - - return 1u; - }); - } else { - uint32_t b_num_invocations = num_invocations / 2; - uint32_t a_num_invocations = - num_invocations - b_num_invocations; - - uint32_t a_offset = invocation_offset; - uint32_t b_offset = a_offset + a_num_invocations; - - // FIXME, again priority issues here - addToRunQueueImpl(run_queue, - [=](Job *job_array, uint32_t cur_tail) { - uint32_t first_idx = - cur_tail & consts::runQueueIndexMask; - - uint32_t second_idx = - (cur_tail + 1) & consts::runQueueIndexMask; - - job_array[first_idx] = Job { - .func = generic_fn, - .data = job_data, - .invocationOffset = a_offset, - .numInvocations = a_num_invocations, - }; - - job_array[second_idx] = Job { - .func = generic_fn, - .data = job_data, - .invocationOffset = b_offset, - .numInvocations = b_num_invocations, - }; - - return 2u; - }); - } -} - -void JobManager::runJob(const int thread_idx, - Context *ctx, - void (*generic_fn)(), - JobContainerBase *job_data, - uint32_t invocation_offset, - uint32_t num_invocations) -{ - ctx->cur_job_id_ = job_data->id; - - if (num_invocations == 0) { - auto fn = (SingleInvokeFn)generic_fn; - fn(ctx, job_data); - return; - } else { - // FIXME, figure out relationship between different queue priorities - // Should the normal priority queue always be the work indicator here? - RunQueue *check_queue = getRunQueue(normal_base_, thread_idx); - - auto fn = (MultiInvokeFn)generic_fn; - fn(ctx, job_data, invocation_offset, num_invocations, check_queue); - } -} - -void JobManager::workerThread( - const int thread_idx, - void *context_base, - uint32_t num_context_bytes) -{ -#ifndef MADRONA_MW_MODE - (void)num_context_bytes; - Context *ctx = (Context *)context_base; -#endif - - Job cur_job; - - WorkerState &worker_state = - getWorkerState(worker_base_, thread_idx); - - auto runCurJob = [&]() MADRONA_ALWAYS_INLINE { - worker_state.numConsecutiveSchedulerCalls = 0; - -#ifdef MADRONA_MW_MODE - Context *ctx = (Context *)((char *)context_base + - (uint64_t)cur_job.data->worldID * (uint64_t)num_context_bytes); -#endif - - runJob(thread_idx, ctx, cur_job.func, cur_job.data, - cur_job.invocationOffset, cur_job.numInvocations); - }; - - while (true) { - WorkerControl worker_ctrl = WorkerControl::LoopIdle; - if (num_high_.load_relaxed() > 0) { - worker_ctrl = getNextJob(high_base_, thread_idx, thread_idx, - false, &cur_job); - - if (worker_ctrl == WorkerControl::Run) { - num_high_.fetch_sub_relaxed(1); - } - } - - if (worker_ctrl != WorkerControl::Run) [[likely]] { - worker_ctrl = getNextJob(normal_base_, thread_idx, thread_idx, - true, &cur_job); - } - - if (worker_ctrl == WorkerControl::Run) { - runCurJob(); - } else if (worker_ctrl == WorkerControl::LoopIdle) { - // No available work and couldn't run scheduler - workerPause(); - worker_state.numIdleLoops++; - } else if (worker_ctrl == WorkerControl::LoopBusy) { - continue; - } else if (worker_ctrl == WorkerControl::Sleep) [[unlikely]] { - worker_state.wakeUp.wait(0); - uint32_t wakeup_idx = worker_state.wakeUp.load_relaxed(); - if (wakeup_idx == ~0_u32) [[unlikely]] { - break; - } - - int wakeup_search_idx = (int)wakeup_idx - 1; - - worker_ctrl = getNextJob(normal_base_, thread_idx, - wakeup_search_idx, false, &cur_job); - - if (worker_ctrl == WorkerControl::Run) { - runCurJob(); - } - } else if (worker_ctrl == WorkerControl::Exit) [[unlikely]] { - break; - } - } -} - -void JobManager::ioThread( - const int thread_idx, - void *context_base, - uint32_t num_context_bytes) -{ -#ifndef MADRONA_MW_MODE - (void)num_context_bytes; - Context *ctx = (Context *)context_base; -#endif - - Job cur_job; - - while (true) { - WorkerControl worker_ctrl = getNextJob(io_base_, thread_idx, - thread_idx, false, &cur_job); - - if (worker_ctrl != WorkerControl::Run) { - io_sema_.acquire(); - } - -#ifdef MADRONA_MW_MODE - Context *ctx = (Context *)((char *)context_base + - (uint64_t)cur_job.data->worldID * (uint64_t)num_context_bytes); -#endif - - runJob(thread_idx, ctx, cur_job.func, cur_job.data, - cur_job.invocationOffset, cur_job.numInvocations); - } -} - -void JobManager::waitForAllFinished() -{ - for (int i = 0, n = threads_.size(); i < n; i++) { - threads_[i].join(); - } -} - -} diff --git a/src/mw/CMakeLists.txt b/src/mw/CMakeLists.txt index b3012ea8..1141fb3d 100644 --- a/src/mw/CMakeLists.txt +++ b/src/mw/CMakeLists.txt @@ -45,13 +45,6 @@ endif() set(DEVICE_SRC_DIR "${MADRONA_SRC_DIR}/mw/device") -set(JOB_SYS_DEVICE_SOURCES - ${DEVICE_SRC_DIR}/job.cpp - ${DEVICE_SRC_DIR}/state.cpp - ${DEVICE_SRC_DIR}/crash.cpp - ${DEVICE_SRC_DIR}/const.cpp -) - set(BVH_DEVICE_SOURCES ${DEVICE_SRC_DIR}/bvh.cpp ${DEVICE_SRC_DIR}/memory.cpp @@ -76,12 +69,6 @@ set(TASK_GRAPH_DEVICE_SOURCES ${MADRONA_SRC_DIR}/render/ecs_system.cpp ) -set(JOB_SYS_INTERNAL_SRC_LIST) -foreach(f ${JOB_SYS_DEVICE_SOURCES}) - set(JOB_SYS_INTERNAL_SRC_LIST "${JOB_SYS_INTERNAL_SRC_LIST}\"${f}\", ") -endforeach() -set(JOB_SYS_INTERNAL_SRC_LIST "${JOB_SYS_INTERNAL_SRC_LIST}") - set(TASK_GRAPH_INTERNAL_SRC_LIST) foreach(f ${TASK_GRAPH_DEVICE_SOURCES}) set(TASK_GRAPH_INTERNAL_SRC_LIST "${TASK_GRAPH_INTERNAL_SRC_LIST}\"${f}\", ") @@ -127,7 +114,6 @@ target_compile_definitions(madrona_mw_gpu PRIVATE MADRONA_NVRTC_INCLUDE_DIRS=${NVRTC_INCLUDE_DIRS_LIST} MADRONA_NVRTC_OPTIONS=${NVRTC_OPTIONS} - MADRONA_MW_GPU_JOB_SYS_INTERNAL_CPP=${JOB_SYS_INTERNAL_SRC_LIST} MADRONA_MW_GPU_TASK_GRAPH_INTERNAL_CPP=${TASK_GRAPH_INTERNAL_SRC_LIST} MADRONA_MW_GPU_BVH_INTERNAL_CPP=${BVH_INTERNAL_SRC_LIST} MADRONA_MW_GPU_DEVICE_SRC_DIR=\"${DEVICE_SRC_DIR}\" diff --git a/src/mw/cuda_exec.cpp b/src/mw/cuda_exec.cpp index abfb2424..256f6fdc 100644 --- a/src/mw/cuda_exec.cpp +++ b/src/mw/cuda_exec.cpp @@ -323,11 +323,6 @@ static constexpr uint32_t numEntryQueueThreads = 512; using GPUImplConsts = mwGPU::madrona::mwGPU::GPUImplConsts; -enum class ExecutorMode { - JobSystem, - TaskGraph, -}; - struct GPUCompileResults { CUmodule mod; std::string initECSName; @@ -405,8 +400,6 @@ struct GPUKernels { CUfunction initECS; CUfunction initWorlds; CUfunction initTasks; - CUfunction queueUserInit; - CUfunction queueUserRun; CUfunction initBVHParams; CUfunction destroyECS; }; @@ -480,94 +473,6 @@ struct MWCudaExecutor::Impl { std::vector timingGroups; }; -static void getUserEntries(const char *entry_class, CUmodule mod, - const char **compile_flags, - uint32_t num_compile_flags, - CUfunction *init_out, CUfunction *run_out) -{ - static const char mangle_code_postfix[] = R"__( -#include - -namespace madrona { namespace mwGPU { - -template __global__ void submitInit(uint32_t, void *) {} -template __global__ void submitRun(uint32_t) {} - -} } -)__"; - - static const char init_template[] = - "::madrona::mwGPU::submitInit<::"; - static const char run_template[] = - "::madrona::mwGPU::submitRun<::"; - - std::string_view entry_view(entry_class); - - // If user prefixed with ::, trim off as it will be added later - if (entry_view[0] == ':' && entry_view[1] == ':') { - entry_view = entry_view.substr(2); - } - - std::string fwd_declare; - - // Find all namespace separators - int num_namespaces = 0; - size_t prev_off = 0, off = 0; - while ((off = entry_view.find("::", prev_off)) != std::string_view::npos) { - auto ns_view = entry_view.substr(prev_off, off - prev_off); - - fwd_declare += "namespace "; - fwd_declare += ns_view; - fwd_declare += " { "; - - prev_off = off + 2; - num_namespaces++; - } - - auto class_view = entry_view.substr(prev_off); - if (class_view.size() == 0) { - FATAL("Invalid entry class name\n"); - } - - fwd_declare += "class "; - fwd_declare += class_view; - fwd_declare += "; "; - - for (int i = 0; i < num_namespaces; i++) { - fwd_declare += "} "; - } - - std::string mangle_code = std::move(fwd_declare); - mangle_code += mangle_code_postfix; - - std::string init_name = init_template; - init_name += entry_view; - init_name += ">"; - - std::string run_name = run_template; - run_name += entry_view; - run_name += ">"; - - nvrtcProgram prog; - REQ_NVRTC(CudaDynamicLoader::nvrtcCreateProgram(&prog, mangle_code.c_str(), "mangle.cpp", - 0, nullptr, nullptr)); - - REQ_NVRTC(CudaDynamicLoader::nvrtcAddNameExpression(prog, init_name.c_str())); - REQ_NVRTC(CudaDynamicLoader::nvrtcAddNameExpression(prog, run_name.c_str())); - - REQ_NVRTC(CudaDynamicLoader::nvrtcCompileProgram(prog, num_compile_flags, compile_flags)); - - const char *init_lowered; - REQ_NVRTC(CudaDynamicLoader::nvrtcGetLoweredName(prog, init_name.c_str(), &init_lowered)); - const char *run_lowered; - REQ_NVRTC(CudaDynamicLoader::nvrtcGetLoweredName(prog, run_name.c_str(), &run_lowered)); - - REQ_CU(CudaDynamicLoader::cuModuleGetFunction(init_out, mod, init_lowered)); - REQ_CU(CudaDynamicLoader::cuModuleGetFunction(run_out, mod, run_lowered)); - - REQ_NVRTC(CudaDynamicLoader::nvrtcDestroyProgram(&prog)); -} - static MegakernelCache loadMegakernelCache(const std::string &cache_path) { std::ifstream cache_file(cache_path, @@ -676,7 +581,7 @@ static GPUCompileResults compileCode( const MegakernelConfig *megakernel_cfgs, int64_t num_megakernel_cfgs, CompileConfig::OptMode opt_mode, - ExecutorMode exec_mode, bool verbose_compile) + bool verbose_compile) { const std::string cache_path = getenv("MADRONA_MWGPU_KERNEL_CACHE"); @@ -761,30 +666,10 @@ static GPUCompileResults compileCode( (char *)cubin.data(), cubin.size(), name)); }; - std::string megakernel_job_prefix = R"__(#include "megakernel_job_impl.inl" - -extern "C" { - -)__"; - std::string megakernel_taskgraph_prefix = R"__(#include "megakernel_impl.inl" extern "C" { -)__"; - - std::string megakernel_job_body = R"__(namespace madrona { -namespace mwGPU { - -static __attribute__((always_inline)) inline void dispatch( - uint32_t func_id, - madrona::JobContainerBase *data, - uint32_t *data_indices, - uint32_t *invocation_offsets, - uint32_t num_launches, - uint32_t grid) -{ - switch (func_id) { )__"; std::string megakernel_taskgraph_body = R"__(namespace madrona { @@ -798,31 +683,14 @@ static __attribute__((always_inline)) inline void dispatch( switch (func_id) { )__"; - std::string megakernel_prefix; - std::string megakernel_body; + std::string megakernel_prefix = megakernel_taskgraph_prefix; + std::string megakernel_body = megakernel_taskgraph_body; std::string megakernel_func_ids; - std::string_view entry_prefix; - std::string_view entry_postfix; - std::string_view entry_params; - std::string_view entry_args; - std::string_view id_prefix; - if (exec_mode == ExecutorMode::JobSystem) { - megakernel_prefix = megakernel_job_prefix; - megakernel_body = megakernel_job_body; - entry_prefix = ".weak .func _ZN7madrona5mwGPU8jobEntry"; - entry_postfix = "EvPNS_16JobContainerBaseEPj"; - entry_params = "(madrona::JobContainerBase *, uint32_t *, uint32_t *, uint32_t, uint32_t);\n"; - entry_args = "(data, data_indices, invocation_offsets, num_launches, grid);\n"; - id_prefix = "_ZN7madrona5mwGPU13JobFuncIDBase"; - } else if (exec_mode == ExecutorMode::TaskGraph) { - megakernel_prefix = megakernel_taskgraph_prefix; - megakernel_body = megakernel_taskgraph_body; - entry_prefix = ".weak .func _ZN7madrona5mwGPU9userEntry"; - entry_postfix = "EvPNS_8NodeBaseEi"; - entry_params = "(madrona::NodeBase *, int32_t);\n"; - entry_args = "(node_data, invocation_offset);\n"; - id_prefix = "_ZN7madrona5mwGPU14UserFuncIDBase"; - } + std::string_view entry_prefix = ".weak .func _ZN7madrona5mwGPU9userEntry"; + std::string_view entry_postfix = "EvPNS_8NodeBaseEi"; + std::string_view entry_params = "(madrona::NodeBase *, int32_t);\n"; + std::string_view entry_args = "(node_data, invocation_offset);\n"; + std::string_view id_prefix = "_ZN7madrona5mwGPU14UserFuncIDBase"; std::string init_ecs_name; std::string init_worlds_name; @@ -1382,7 +1250,6 @@ static BVHKernels buildBVHKernels(const CompileConfig &cfg, static GPUKernels buildKernels(const CompileConfig &cfg, Span megakernel_cfgs, - ExecutorMode exec_mode, int32_t num_sms, std::pair cuda_arch) { @@ -1396,23 +1263,12 @@ static GPUKernels buildKernels(const CompileConfig &cfg, using namespace std; - array job_sys_cpp_files { - MADRONA_MW_GPU_JOB_SYS_INTERNAL_CPP - }; - array task_graph_cpp_files { MADRONA_MW_GPU_TASK_GRAPH_INTERNAL_CPP }; - uint32_t num_exec_srcs = 0; - const char **exec_srcs = nullptr; - if (exec_mode == ExecutorMode::JobSystem) { - num_exec_srcs = job_sys_cpp_files.size(); - exec_srcs = job_sys_cpp_files.data(); - } else if (exec_mode == ExecutorMode::TaskGraph) { - num_exec_srcs = task_graph_cpp_files.size(); - exec_srcs = task_graph_cpp_files.data(); - } + uint32_t num_exec_srcs = task_graph_cpp_files.size(); + const char **exec_srcs = task_graph_cpp_files.data(); size_t num_srcs = num_exec_srcs + cfg.userSources.size(); HeapArray all_cpp_files(num_srcs); @@ -1489,11 +1345,7 @@ static GPUKernels buildKernels(const CompileConfig &cfg, compile_flags.push_back("-DMADRONA_MWGPU_LTO_MODE=1"); } - if (exec_mode == ExecutorMode::JobSystem) { - compile_flags.push_back("-DMARONA_MWGPU_JOB_SYSTEM=1"); - } else if (exec_mode == ExecutorMode::TaskGraph) { - compile_flags.push_back("-DMADRONA_MWGPU_TASKGRAPH=1"); - } + compile_flags.push_back("-DMADRONA_MWGPU_TASKGRAPH=1"); DynArray linker_flags { gpu_arch_flag.c_str(), @@ -1538,7 +1390,7 @@ static GPUKernels buildKernels(const CompileConfig &cfg, fast_compile_flags.data(), fast_compile_flags.size(), linker_flags.data(), linker_flags.size(), megakernel_cfgs.data(), megakernel_cfgs.size(), - opt_mode, exec_mode, verbose_compile); + opt_mode, verbose_compile); HeapArray megakernel_fns(megakernel_cfgs.size()); for (int64_t i = 0; i < megakernel_cfgs.size(); i++) { @@ -1557,8 +1409,6 @@ static GPUKernels buildKernels(const CompileConfig &cfg, .initECS = nullptr, .initWorlds = nullptr, .initTasks = nullptr, - .queueUserInit = nullptr, - .queueUserRun = nullptr, .initBVHParams = nullptr, .destroyECS = nullptr, }; @@ -1566,21 +1416,12 @@ static GPUKernels buildKernels(const CompileConfig &cfg, REQ_CU(CudaDynamicLoader::cuModuleGetFunction(&gpu_kernels.computeGPUImplConsts, gpu_kernels.mod, "madronaMWGPUComputeConstants")); - if (exec_mode == ExecutorMode::JobSystem) { - REQ_CU(CudaDynamicLoader::cuModuleGetFunction(&gpu_kernels.initECS, gpu_kernels.mod, - "madronaMWGPUInitialize")); - // FIXME: getUserEntries is broken - getUserEntries("", gpu_kernels.mod, compile_flags.data(), - compile_flags.size(), &gpu_kernels.queueUserInit, - &gpu_kernels.queueUserRun); - } else if (exec_mode == ExecutorMode::TaskGraph) { - REQ_CU(CudaDynamicLoader::cuModuleGetFunction(&gpu_kernels.initECS, gpu_kernels.mod, - compile_results.initECSName.c_str())); - REQ_CU(CudaDynamicLoader::cuModuleGetFunction(&gpu_kernels.initWorlds, gpu_kernels.mod, - compile_results.initWorldsName.c_str())); - REQ_CU(CudaDynamicLoader::cuModuleGetFunction(&gpu_kernels.initTasks, gpu_kernels.mod, - compile_results.initTasksName.c_str())); - } + REQ_CU(CudaDynamicLoader::cuModuleGetFunction(&gpu_kernels.initECS, gpu_kernels.mod, + compile_results.initECSName.c_str())); + REQ_CU(CudaDynamicLoader::cuModuleGetFunction(&gpu_kernels.initWorlds, gpu_kernels.mod, + compile_results.initWorldsName.c_str())); + REQ_CU(CudaDynamicLoader::cuModuleGetFunction(&gpu_kernels.initTasks, gpu_kernels.mod, + compile_results.initTasksName.c_str())); REQ_CU(CudaDynamicLoader::cuModuleGetFunction(&gpu_kernels.initBVHParams, gpu_kernels.mod, "initBVHParams")); @@ -1796,7 +1637,6 @@ static GPUEngineState initEngineAndUserState( const GPUKernels &gpu_kernels, const BVHKernels &bvh_kernels, const Optional &render_cfg, - ExecutorMode exec_mode, CUdevice cu_gpu, CUcontext cu_ctx, cudaStream_t strm) @@ -1975,23 +1815,12 @@ static GPUEngineState initEngineAndUserState( REQ_CU(CudaDynamicLoader::cuMemcpyHtoD(job_sys_consts_addr, gpu_consts_readback, job_sys_consts_size)); - if (exec_mode == ExecutorMode::JobSystem) { - launchKernel(gpu_kernels.initWorlds, 1, consts::numMegakernelThreads, no_args); - uint32_t num_queue_blocks = utils::divideRoundUp(num_worlds, consts::numEntryQueueThreads); - - launchKernel(gpu_kernels.queueUserInit, num_queue_blocks, - consts::numEntryQueueThreads, init_worlds_args); + launchKernel(gpu_kernels.initECS, 1, 1, init_ecs_args); + uint32_t num_init_blocks = utils::divideRoundUp(num_worlds, consts::numMegakernelThreads); - launchKernel(gpu_kernels.megakernels[0], 1, - consts::numMegakernelThreads, no_args); - } else if (exec_mode == ExecutorMode::TaskGraph) { - launchKernel(gpu_kernels.initECS, 1, 1, init_ecs_args); - uint32_t num_init_blocks = utils::divideRoundUp(num_worlds, consts::numMegakernelThreads); - - launchKernel(gpu_kernels.initWorlds, num_init_blocks, - consts::numMegakernelThreads, init_worlds_args); - launchKernel(gpu_kernels.initTasks, 1, 1, init_tasks_args); - } + launchKernel(gpu_kernels.initWorlds, num_init_blocks, + consts::numMegakernelThreads, init_worlds_args); + launchKernel(gpu_kernels.initTasks, 1, 1, init_tasks_args); REQ_CUDA(cudaStreamSynchronize(strm)); @@ -2058,56 +1887,6 @@ static GPUEngineState initEngineAndUserState( }; } -[[maybe_unused]] static CUgraphExec makeJobSysRunGraph( - CUfunction queue_run_kernel, - CUfunction job_sys_kernel, - uint32_t num_worlds) -{ - auto queue_args = makeKernelArgBuffer(num_worlds); - auto no_args = makeKernelArgBuffer(); - - uint32_t num_queue_blocks = utils::divideRoundUp(num_worlds, - consts::numEntryQueueThreads); - - CUgraph run_graph; - REQ_CU(CudaDynamicLoader::cuGraphCreate(&run_graph, 0)); - - CUDA_KERNEL_NODE_PARAMS kernel_node_params { - .func = queue_run_kernel, - .gridDimX = num_queue_blocks, - .gridDimY = 1, - .gridDimZ = 1, - .blockDimX = consts::numEntryQueueThreads, - .blockDimY = 1, - .blockDimZ = 1, - .sharedMemBytes = 0, - .kernelParams = nullptr, - .extra = queue_args.data(), - .kern = nullptr, - .ctx = nullptr, - }; - - CUgraphNode queue_node; - REQ_CU(CudaDynamicLoader::cuGraphAddKernelNode(&queue_node, run_graph, - nullptr, 0, &kernel_node_params)); - - kernel_node_params.func = job_sys_kernel; - kernel_node_params.gridDimX = 1; - kernel_node_params.blockDimX = consts::numMegakernelThreads; - kernel_node_params.extra = no_args.data(); - - CUgraphNode job_sys_node; - REQ_CU(CudaDynamicLoader::cuGraphAddKernelNode(&job_sys_node, run_graph, - &queue_node, 1, &kernel_node_params)); - - CUgraphExec run_graph_exec; - REQ_CU(CudaDynamicLoader::cuGraphInstantiate(&run_graph_exec, run_graph, 0)); - - REQ_CU(CudaDynamicLoader::cuGraphDestroy(run_graph)); - - return run_graph_exec; -} - static MegakernelConfig processExecConfigOverride(const char *override_str) { auto err = []() { @@ -2337,8 +2116,6 @@ MWCudaExecutor::MWCudaExecutor( // MADRONA_DEBUG_LOG("Kernel cache directory set to: %s\n", kernel_cache_path.c_str()); // MADRONA_DEBUG_LOG("BVH cache directory set to: %s\n", bvh_cache_path.c_str()); - const ExecutorMode exec_mode = ExecutorMode::TaskGraph; - auto strm = cu::makeStream(); CUdevice cu_gpu; @@ -2384,7 +2161,7 @@ MWCudaExecutor::MWCudaExecutor( } GPUKernels gpu_kernels = buildKernels(compile_cfg, megakernel_cfgs, - exec_mode, num_sms, cu_capability); + num_sms, cu_capability); GPUEngineState eng_state = initEngineAndUserState( state_cfg.numWorlds, state_cfg.numWorldDataBytes, @@ -2394,7 +2171,7 @@ MWCudaExecutor::MWCudaExecutor( state_cfg.numExportedBuffers, gpu_kernels, bvh_kernels, render_cfg, - exec_mode, cu_gpu, cu_ctx, strm); + cu_gpu, cu_ctx, strm); TaskGraphsState taskgraphs_state { .megakernels = std::move(gpu_kernels.megakernels), diff --git a/src/mw/device/include/madrona/job.hpp b/src/mw/device/include/madrona/job.hpp deleted file mode 100644 index a206a0cf..00000000 --- a/src/mw/device/include/madrona/job.hpp +++ /dev/null @@ -1,67 +0,0 @@ -/* - * Copyright 2021-2022 Brennan Shacklett and contributors - * - * Use of this source code is governed by an MIT-style - * license that can be found in the LICENSE file or at - * https://opensource.org/licenses/MIT. - */ -#pragma once - -#include -#include -#include - -namespace madrona { - -struct JobID { - uint32_t gen; - uint32_t id; - - static constexpr inline JobID none(); -}; - -struct JobContainerBase { - JobID jobID; - uint32_t worldID; - uint32_t numInvocations; - uint32_t numDependencies; - - template struct DepsArray; -}; - -template -struct JobContainer : public JobContainerBase { - [[no_unique_address]] DepsArray dependencies; - [[no_unique_address]] Fn fn; - - template - inline JobContainer(JobID job_id, uint32_t world_id, - uint32_t num_invocations, Fn &&fn, DepTs ...deps); -}; - -struct Job { - JobContainerBase *data; - uint32_t funcID; - uint32_t numCombinedJobs; - uint32_t numBytesPerJob; -}; - -class JobManager { -public: - uint32_t numOutstandingInvocations; - - AtomicU32 freeTrackerHead; - - bool startBlockIter(RunnableJob *out_job); - void finishBlockIter(); - - static inline JobManager * get(); - - template - static inline ContextT makeContext(JobID job_id, uint32_t grid_id, - uint32_t world_id, uint32_t lane_id); -}; - -} - -#include "job.inl" diff --git a/src/mw/device/include/madrona/job.inl b/src/mw/device/include/madrona/job.inl deleted file mode 100644 index b5e530f3..00000000 --- a/src/mw/device/include/madrona/job.inl +++ /dev/null @@ -1,109 +0,0 @@ -#include - -#include - -#include "mw_gpu/worker_init.hpp" -#include "mw_gpu/const.hpp" - -namespace madrona { - -namespace mwGPU { -namespace consts { - -inline constexpr uint32_t numWarpThreads = 32; -inline constexpr uint32_t numMegakernelThreads = 256; -inline constexpr uint32_t numMegakernelWarps = - numMegakernelThreads / numWarpThreads; - -} -} - -constexpr JobID JobID::none() -{ - return JobID { - ~0u, - ~0u, - }; -} - -template -struct JobContainerBase::DepsArray { - JobID dependencies[N]; - - template - inline DepsArray(DepTs ...deps) - : dependencies { deps ... } - {} -}; - -template <> struct JobContainerBase::DepsArray<0> { - template - inline DepsArray(DepTs...) {} -}; - -template -template -JobContainer::JobContainer(JobID job_id, - uint32_t world_id, - uint32_t num_invocations, - Fn &&func, - DepTs ...deps) - : JobContainerBase { - .jobID = job_id, - .worldID = world_id, - .numInvocations = num_invocations, - .numDependencies = N, - }, - dependencies(deps...), - fn(std::forward(func)) -{} - -JobManager * JobManager::get() -{ - return (JobManager *)GPUImplConsts::get().jobSystemAddr; -} - -mwGPU::SharedJobTracker * JobManager::getSharedJobTrackers() -{ - return (mwGPU::SharedJobTracker *)((char *)this + - mwGPU::GPUImplConsts::get().sharedJobTrackerOffset); -} - -mwGPU::UserJobTracker * JobManager::getUserJobTrackers() -{ - return (mwGPU::UserJobTracker *)((char *)this + - mwGPU::GPUImplConsts::get().userJobTrackerOffset); -} - -template -ContextT JobManager::makeContext(JobID job_id, uint32_t grid_id, - uint32_t world_id, uint32_t lane_id) -{ - using DataT = std::conditional_t, - WorldBase, typename ContextT::WorldDataT>; - - DataT *world_data; - - // If this is being called with the generic Context base class, - // we need to look up the size of the world data in constant memory - if constexpr (std::is_same_v) { - char *world_data_base = - (char *)mwGPU::GPUImplConsts::get().worldDataAddr; - world_data = (DataT *)(world_data_base + world_id * - mwGPU::GPUImplConsts::get().numWorldDataBytes); - } else { - DataT *world_data_base = - (DataT *)mwGPU::GPUImplConsts::get().worldDataAddr; - - world_data = world_data_base + world_id; - } - - return ContextT(world_data, WorkerInit { - .jobID = job_id, - .gridID = grid_id, - .worldID = world_id, - .laneID = lane_id, - }); -} - -} diff --git a/src/mw/device/include/madrona/mw_gpu.hpp b/src/mw/device/include/madrona/mw_gpu.hpp deleted file mode 100644 index 4870ffe1..00000000 --- a/src/mw/device/include/madrona/mw_gpu.hpp +++ /dev/null @@ -1,26 +0,0 @@ -/* - * Copyright 2021-2022 Brennan Shacklett and contributors - * - * Use of this source code is governed by an MIT-style - * license that can be found in the LICENSE file or at - * https://opensource.org/licenses/MIT. - */ -#pragma once - -#include - -#include "mw_gpu/entry.hpp" - -namespace madrona { - -template -class GPUJobEntry : mwGPU::EntryBase { -public: - static void submitInit(uint32_t invocation_idx, void *world_init_ptr); - static void submitRun(uint32_t invocation_idx); - -private: - static ContextT makeFakeContext(uint32_t invocation_idx); -}; - -#include "mw_gpu.inl" diff --git a/src/mw/device/include/madrona/mw_gpu.inl b/src/mw/device/include/madrona/mw_gpu.inl deleted file mode 100644 index a004cdc9..00000000 --- a/src/mw/device/include/madrona/mw_gpu.inl +++ /dev/null @@ -1,50 +0,0 @@ -/* - * Copyright 2021-2022 Brennan Shacklett and contributors - * - * Use of this source code is governed by an MIT-style - * license that can be found in the LICENSE file or at - * https://opensource.org/licenses/MIT. - */ -#pragma once - -#include "mw_gpu/worker_init.hpp" - -namespace madrona { - -template -void GPUEntry::submitInit(uint32_t invocation_idx, - void *world_init_ptr) -{ - ContextT ctx = makeFakeContext(invocation_idx); - - InitT *base_init = (InitT *)world_init_ptr; - InitT *init = base_init + ctx.worldID(); - - ctx.submit([init](ContextT &ctx) { - BaseT::init(ctx, *init); - }, false); -} - -template -void GPUEntry::submitRun(uint32_t invocation_idx) -{ - ContextT ctx = makeFakeContext(invocation_idx); - - ctx.submit([](ContextT &ctx) { - BaseT::run(ctx); - }, false); -} - -template -ContextT GPUEntry::makeFakeContext( - uint32_t invocation_idx) -{ - uint32_t lane_id = - invocation_idx % mwGPU::consts::numWarpThreads; - uint32_t world_id = lane_id; - - return JobManager::makeContext(JobID { 0, 0 }, 0, - world_id, lane_id); -} - -} diff --git a/src/mw/device/job.cpp b/src/mw/device/job.cpp deleted file mode 100644 index e2d01416..00000000 --- a/src/mw/device/job.cpp +++ /dev/null @@ -1,999 +0,0 @@ -/* - * Copyright 2021-2022 Brennan Shacklett and contributors - * - * Use of this source code is governed by an MIT-style - * license that can be found in the LICENSE file or at - * https://opensource.org/licenses/MIT. - */ -#include -#include -#include - -#include -#include -#include - -#include "mw_gpu/const.hpp" -#include "mw_gpu/cu_utils.hpp" - -// Considerations: -// - Enforcing end-user data safety constraints around iteration & creation / deletion: fundamentally, a job needs to not run until it can be run by all worlds. -// - This means a given launch should have all the union of all dependencies of all instances of the same launch across worlds. Practically speaking, this means all the children launches of a given parent need to be deferred until the parent has completed across all worlds. Then a merging step has to run, where children jobs that match across worlds need to be grouped together into single launches with shared dependencies. -// - Challenges: -// - Currently job "uniqueness" isn't enforced. This means job A can launch the same function pointer twice, and the second launch can depend on the first. This could be changed, but may be jarring given the current lambda API -// - The above issue implies that searching through the list of children, merging based on function ptr alone is not enough. One solution is (func_ptr, # of launches of ptr in this job). That's annoying because now you need to track per func ptr state while building -// - Advantages: -// - This scheme enforces maximum merging of jobs. If this wasn't the case, there would need to be some other mechanism to recover common jobs in conditional execution cases. -// - Disadvantages: -// - Lots of synchronization, less ability to overlap. Partly lose a major advantage of the megakernel which is that there is now no driver mandated synchronization -// - Sketch: -// - Every running job has a num_worlds wide log array. -// - -using std::atomic_uint32_t; -using std::memory_order; - -extern "C" { -__global__ void madronaMWGPUMegakernel(uint32_t func_id, madrona::JobContainerBase *data, uint32_t *data_indices, uint32_t *invocation_offsets, uint32_t num_launches, uint32_t grid); -} - -namespace madrona { -namespace mwGPU { - -namespace consts { - -static constexpr uint32_t allActive = 0xFFFFFFFF; -static constexpr uint32_t jobTrackerTerm = ~0u; - -static constexpr uint32_t jobsPerWaitQueue = 16384; -static constexpr uint32_t numWaitQueues = 64; - -} - -struct JobTracker { - uint32_t parent; - std::atomic_uint32_t numOutstanding; - std::atomic_uint32_t remainingInvocations; -}; - -struct ThreadblockData { - ChunkAllocator::Cache chunkCache; - std::atomic_uint32_t curChunkID; - std::atomic_uint32_t remainingChunkBytes; -}; - -static __shared__ ThreadblockData tbData; - -struct WaitQueue { - SpinLock lock; - uint32_t numWaiting; - Job waitingJobs[consts::jobsPerWaitQueue]; -}; - -static inline WaitQueue * getWaitQueues(JobManager *mgr, uint32_t idx) -{ - return (WaitQueue *)((char *)mgr + GPUImplConsts::get().waitQueueOffset); -} - -static inline Job *getBaseJobList(JobManager *mgr) -{ - return (Job *)((char *)mgr + GPUImplConsts::get().jobListOffset); -} - -static void initializeJobSystem(JobManager *mgr) -{ - if (threadIdx.x == 0) { - mgr->numOutstandingInvocations = 0; - - for (int i = 0; i < 8; i++) { - mgr->activeGrids[i] = 0; - } - - mgr->freeTrackerHead.store(JobID { 0, 0 }, std::memory_order_relaxed); - } - - __syncthreads(); - - auto grids = getGridInfo(mgr); - - for (int i = threadIdx.x; i < (int)consts::numJobGrids; i += blockDim.x) { - grids[i].waitJobHead.store(0, std::memory_order_relaxed); - grids[i].waitJobTail.store(0, std::memory_order_relaxed); - grids[i].waitQueueLock.store(0, std::memory_order_relaxed); - grids[i].numRunning.store(0, std::memory_order_relaxed); - } - - auto shared_trackers = getSharedJobTrackers(mgr); - - int num_shared_trackers = - GPUImplConsts::get().maxJobsPerGrid * consts::numJobGrids; - - for (int i = threadIdx.x; i < num_trackers; i += blockDim.x) { - JobTracker &tracker = trackers[i]; - tracker.gen.store(0, std::memory_order_relaxed); - if (i < num_trackers - 1) { - tracker.parent.store(i + 1, std::memory_order_relaxed); - } else { - tracker.parent.store(consts::jobTrackerTerm, std::memory_order_relaxed); - } - tracker.numOutstanding.store(0, std::memory_order_relaxed); - } - - std::atomic_thread_fence(std::memory_order_release); -} - -static inline const JobID * getJobDependencies(JobContainerBase *job_base) -{ - return (const JobID *)((char *)job_base + sizeof(JobContainerBase)); -} - -static inline bool isJobReady(JobManager *job_mgr, Job &job) -{ - const JobTracker *trackers = getJobTrackers(job_mgr); - for (int job_idx = 0; job_idx < job.numCombinedJobs; job_idx++) { - JobContainerBase *job_data = (JobContainerBase *)( - (char *)job.data + job.numBytesPerJob * job_idx); - - int num_deps = job_data->numDependencies; - - const JobID *dependencies = getJobDependencies(job_data); - for (int i = 0; i < num_deps; i++) { - JobID dependency = dependencies[i]; - - if (trackers[dependency.id].gen == dependency.gen) { - return false; - } - } - } - - return true; -} - -static inline void *allocateJobData(uint32_t total_bytes) -{ - // FIXME - return malloc(total_bytes); -} - -static inline void freeJobData(void *data) -{ - free(data); -} - -static inline bool checkGEWrapped(uint32_t a, uint32_t b) -{ - return uint32_t(a - b) <= (1u << 31u); -} - -static inline bool checkLTWrapped(uint32_t a, uint32_t b) -{ - return uint32_t(a - b) > (1u << 31u); -} - -static inline uint32_t wrapQueueIdx(uint32_t idx, uint32_t num_elems) -{ - // Assumes num_elems is a power of 2 - return idx & (num_elems - 1); -} - -// Only a single thread block can run this function -static void jobLoop() -{ - uint32_t lane_id = threadIdx.x % consts::numWarpThreads; - uint32_t warp_id = threadIdx.x / consts::numWarpThreads; - - constexpr uint32_t total_num_warps = consts::numJobSystemKernelThreads / - consts::numWarpThreads; - - constexpr uint32_t grids_per_warp = - consts::numJobGrids / total_num_warps; - - auto job_mgr = getJobManager(); - auto base_job_grids = getGridInfo(job_mgr); - auto base_job_list = getBaseJobList(job_mgr); - - const uint32_t max_jobs_per_grid = GPUImplConsts::get().maxJobsPerGrid; - - static __shared__ uint32_t active_grids_tmp[8]; - - cuda::barrier cpy_barrier; - init(&cpy_barrier, 1); - - auto findFirstReadyJob = [job_mgr, lane_id, max_jobs_per_grid]( - Job *job_list, uint32_t job_head, uint32_t job_tail) { - int first_job_idx = -1; - if (lane_id == 0) { - for (uint32_t job_idx = job_head; - checkLTWrapped(job_idx, job_tail); job_idx++) { - int wrapped_idx = - (int)wrapQueueIdx(job_idx, max_jobs_per_grid); - Job &cur_job = job_list[wrapped_idx]; - - if (isJobReady(job_mgr, cur_job)) { - first_job_idx = wrapped_idx; - break; - } - } - } - - return __shfl_sync(consts::allActive, first_job_idx, 0); - }; - - auto getFreeGrid = [lane_id]() { - int run_grid_idx = -1; - if (lane_id == 0) { -#pragma unroll - for (int bitfield_idx = 0; bitfield_idx < 8; bitfield_idx++) { - uint32_t *grid_bitfield_ptr = &active_grids_tmp[bitfield_idx]; - - uint32_t old_bitfield, set_bitfield; - do { - old_bitfield = *grid_bitfield_ptr; - - uint32_t inverse = ~old_bitfield; - - // All grids running - if (inverse == 0) { - run_grid_idx = -1; - break; - } - - uint32_t idx = 31 - __clz(inverse); - - uint32_t mask = 1 << idx; - - set_bitfield = old_bitfield | mask; - - run_grid_idx = idx; - } while (atomicCAS(grid_bitfield_ptr, old_bitfield, - set_bitfield)); - - if (run_grid_idx != -1) { - run_grid_idx = bitfield_idx * 32 + run_grid_idx; - - break; - } - } - } - - return __shfl_sync(consts::allActive, run_grid_idx, 0); - }; - - auto nextLoopSetup = [job_mgr, warp_id, lane_id]() { - std::atomic_thread_fence(std::memory_order_acquire); - - if (warp_id == 0 && lane_id == 0) { - for (int i = 0; i < 8; i++) { - active_grids_tmp[i] = job_mgr->activeGrids[i]; - } - } - - __syncthreads(); - }; - - nextLoopSetup(); - while (true) { - for (int wait_grid_offset = 0; wait_grid_offset < (int)grids_per_warp; - wait_grid_offset++) { - uint32_t wait_grid_idx = - warp_id * grids_per_warp + wait_grid_offset; - - JobGridInfo &wait_grid = base_job_grids[wait_grid_idx]; - Job *waiting_jobs = base_job_list + - wait_grid_idx * max_jobs_per_grid; - - // Relaxed is safe for head & tail, because - // nextLoopSetup() does an acquire barrier - uint32_t job_head = - wait_grid.waitJobHead.load(std::memory_order_relaxed); - // Cache the value of job tail, and use it across the warp, - // it can be incremented by other threads - uint32_t job_tail; - if (lane_id == 0) { - job_tail = - wait_grid.waitJobTail.load(std::memory_order_relaxed); - } - job_tail = __shfl_sync(consts::allActive, job_tail, 0); - - int first_job_idx = - findFirstReadyJob(waiting_jobs, job_head, job_tail); - if (first_job_idx == -1) { - continue; - } - - int run_grid_idx = getFreeGrid(); - if (run_grid_idx == -1) { - break; - } - - JobGridInfo &run_grid = base_job_grids[run_grid_idx]; - - uint32_t first_func_id = waiting_jobs[first_job_idx].funcID; - auto isJobMergable = [first_func_id, job_mgr](Job &job) { - return job.funcID == first_func_id && isJobReady(job_mgr, job); - }; - - uint32_t num_bytes_per_job = - waiting_jobs[first_job_idx].numBytesPerJob; - - uint32_t total_num_jobs = 0; - uint32_t total_num_invocations = 0; - - // Could start from the unwrapped version of first_job_idx, - // but would need to change findFirstReadyJob to return - // a separate failure boolean - for (uint32_t job_offset = job_head; - checkLTWrapped(job_offset, job_tail); - job_offset += consts::numWarpThreads) { - uint32_t job_idx = job_offset + lane_id; - - bool inbounds = checkLTWrapped(job_idx, job_tail); - - // Force out of bounds indices in bounds - if (!inbounds) { - job_idx = job_offset; - } - - Job cur_job = - waiting_jobs[wrapQueueIdx(job_idx, max_jobs_per_grid)]; - - bool merge_job = inbounds && isJobMergable(cur_job); - uint32_t merge_mask = - __ballot_sync(consts::allActive, merge_job); - - uint32_t cur_num_jobs = - merge_job ? cur_job.numCombinedJobs : 0_u32; - - uint32_t num_prior_jobs = total_num_jobs + - warpExclusiveScan(lane_id, cur_num_jobs); - - // Copy job data into grid's run buffer - if (merge_job) { - cuda::memcpy_async(run_grid.runData.buf + - num_prior_jobs * num_bytes_per_job, cur_job.data, - num_bytes_per_job * cur_num_jobs, cpy_barrier); - - assert((num_prior_jobs + cur_num_jobs) * num_bytes_per_job <= - 1024 * 1024); - } - - // FIXME: this is a potentially massive loop that one thread - // has to deal with - uint32_t combined_num_invocations = 0; - for (int job_idx = 0; job_idx != (int)cur_num_jobs; job_idx++) { - JobContainerBase *job_data = (JobContainerBase *)( - (char *)cur_job.data + job_idx * num_bytes_per_job); - - uint32_t num_invocations = job_data->numInvocations; - combined_num_invocations += num_invocations; - } - - uint32_t num_prior_invocations = total_num_invocations + - warpExclusiveScan(lane_id, combined_num_invocations); - - uint32_t num_setup_invocations = 0; - for (int job_idx = 0; job_idx != (int)cur_num_jobs; job_idx++) { - JobContainerBase *job_data = (JobContainerBase *)( - (char *)cur_job.data + job_idx * num_bytes_per_job); - - uint32_t num_invocations = job_data->numInvocations; - - for (int i = 0; i != (int)num_invocations; i++) { - run_grid.jobDataIndices[(int)num_prior_invocations + - num_setup_invocations] = num_prior_jobs + job_idx; - run_grid.jobInvocationOffsets[(int)num_prior_invocations + - num_setup_invocations] = i; - - num_setup_invocations++; - - assert(num_prior_invocations + num_setup_invocations < - 65536 * 16); - } - } - - total_num_invocations = - num_prior_invocations + combined_num_invocations; - total_num_jobs = num_prior_jobs + cur_num_jobs; - - // Get current running total of jobs merged together for launch - uint32_t top_merge_thread = getHighestSetBit(merge_mask); - total_num_invocations = __shfl_sync(consts::allActive, - total_num_invocations, top_merge_thread); - total_num_jobs = __shfl_sync(consts::allActive, - total_num_jobs, top_merge_thread); - } - - // Wait for all the async copies - cpy_barrier.arrive_and_wait(); - - __syncwarp(); - - uint32_t base_wait_coalesce_idx = job_tail - 1u; - - // Free job data for jobs that have been copied into the run - // data block, coalesce waiting job list - // Unfortunately, this loop is in reverse, because the list needs - // to be coalesced into the tail - for (uint32_t job_offset = job_tail - 1u; - checkGEWrapped(job_offset, job_head); - job_offset -= consts::numWarpThreads) { - - uint32_t job_idx = job_offset - lane_id; - - bool inbounds = checkGEWrapped(job_idx, job_head); - - if (!inbounds) { - job_idx = job_offset; - } - - Job cur_job = - waiting_jobs[wrapQueueIdx(job_idx, max_jobs_per_grid)]; - - bool mergable = isJobMergable(cur_job); - bool coalesceable = inbounds && !mergable; - - if (inbounds && mergable) { - freeJobData(cur_job.data); - } - - // The sync here also ensures all threads are done - // using cur_job before any pointers are overwritten - // when coalescing the list - uint32_t coalesce_mask = - __ballot_sync(consts::allActive, coalesceable); - - // Coalesce jobs that won't be launched to make the waiting - // list contiguous - if (coalesceable) { - // Lower threads in the warp are farther ahead in the - // array due to reading backwards - uint32_t coalesce_idx = base_wait_coalesce_idx - - getNumLowerSetBits(coalesce_mask, lane_id); - - if (coalesce_idx != job_idx) { - int wrapped_coalesce_idx = - wrapQueueIdx(coalesce_idx, max_jobs_per_grid); - waiting_jobs[wrapped_coalesce_idx] = cur_job; - } - - base_wait_coalesce_idx = coalesce_idx - 1u; - } - - if (coalesce_mask != 0) { - uint32_t top_coalesce_thread = - getHighestSetBit(coalesce_mask); - base_wait_coalesce_idx = __shfl_sync(consts::allActive, - base_wait_coalesce_idx, top_coalesce_thread); - } - } - - - if (lane_id == 0) { - wait_grid.waitJobHead.store(base_wait_coalesce_idx + 1, - std::memory_order_relaxed); - } - - std::atomic_thread_fence(std::memory_order_release); - - if (lane_id == 0) { - uint32_t num_blocks = utils::divideRoundUp(total_num_invocations, - consts::numJobLaunchKernelThreads); - - run_grid.numRunning.store(total_num_invocations, - std::memory_order_relaxed); - - madronaMWGPUMegakernel<<>>( - first_func_id, - (JobContainerBase *)run_grid.runData.buf, - run_grid.jobDataIndices, run_grid.jobInvocationOffsets, - total_num_invocations, run_grid_idx); - } - } - - __nanosleep(500); - - // Call this function at the end of the loop, in order to use - // the same __syncthreads / acquire call for reading the activeGrid - // bitfields and checking numOutstandingInvocations - nextLoopSetup(); - - if (job_mgr->numOutstandingInvocations == 0) { - break; - } - } -} - -static inline uint32_t computeMaxNumJobs(uint32_t num_worlds) -{ - // FIXME: scaling linearly like this probably doesn't make sense - return consts::maxNumJobsPerWorld * num_worlds; -} - -static inline JobID allocateJobTrackerSlot(JobManager *job_mgr, - JobTracker *trackers) -{ - JobID cur_head = - job_mgr->freeTrackerHead.load(std::memory_order_acquire); - - JobID new_head; - - do { - if (cur_head.id == consts::jobTrackerTerm) { - break; - } - - new_head.gen = cur_head.gen + 1; - new_head.id = trackers[cur_head.id].parents[0]; - } while (!job_mgr->freeTrackerHead.compare_exchange_weak( - cur_head, new_head, memory_order::release, - memory_order::acquire)); - - uint32_t job_id = cur_head.id; - - // FIXME - if (job_id == consts::jobTrackerTerm) { - assert(false); - } - - uint32_t gen = tracker.gen.load(std::memory_order_relaxed); - - return JobID { - gen, - job_id, - }; -} - -static inline void freeJobTrackerSlot(uint32_t job_id) -{ - auto job_mgr = getJobManager(); - JobTracker *trackers = getJobTrackers(job_mgr); - - JobTracker &tracker = trackers[job_id]; - tracker.gen = tracker.gen + 1; - - JobID new_head; - new_head.id = job_id; - - JobID cur_head = job_mgr->freeTrackerHead.load( - std::memory_order_relaxed); - - do { - new_head.gen = cur_head.gen + 1; - - tracker.parents[0] = cur_head.id; - } while (!job_mgr->freeTrackerHead.compare_exchange_weak( - cur_head, new_head, - memory_order::release, memory_order::relaxed)); -} - -static inline void decrementJobTracker(JobTracker *job_trackers, JobID job_id) -{ - uint32_t cur_id = job_id.id; - while (cur_id != consts::jobTrackerTerm) { - JobTracker &tracker = job_trackers[cur_id]; - - uint32_t prev_outstanding = - tracker.numOutstanding.fetch_sub(1, std::memory_order_acq_rel); - - if (prev_outstanding == 1 && - tracker.remainingInvocations.load(std::memory_order_relaxed) == 0) { - uint32_t parent = tracker.parent; - - freeJobTrackerSlot(cur_id); - - cur_id = parent; - } else { - break; - } - } -} - -// This function should only be called by the wave leader -static inline void queueMultiJobInWaitList( - uint32_t func_id, JobContainerBase *data, uint32_t grid_id, - uint32_t num_jobs, uint32_t total_num_invocations, - uint32_t num_bytes_per_job) -{ - Job job { - .data = data, - .funcID = func_id, - .numCombinedJobs = num_jobs, - .numBytesPerJob = num_bytes_per_job, - }; - - auto job_mgr = getJobManager(); - - const auto base_job_grids = getGridInfo(job_mgr); - const auto base_job_list = getBaseJobList(job_mgr); - const uint32_t max_jobs_per_grid = GPUImplConsts::get().maxJobsPerGrid; - - JobGridInfo &cur_grid = base_job_grids[grid_id]; - Job *job_list = base_job_list + grid_id * max_jobs_per_grid; - - // Get lock - while (cur_grid.waitQueueLock.exchange(1, std::memory_order_acq_rel)) {} - - uint32_t cur_job_pos = cur_grid.waitJobTail.load(std::memory_order_acquire); - job_list[wrapQueueIdx(cur_job_pos, max_jobs_per_grid)] = job; - - cur_grid.waitJobTail.fetch_add(1, std::memory_order_relaxed); - - cur_grid.waitQueueLock.store(0, std::memory_order_relaxed); - - atomicAdd(&job_mgr->numOutstandingInvocations, total_num_invocations); - - std::atomic_thread_fence(std::memory_order_release); -} - -} - -bool JobManager::startBlockIter(uint32_t block_idx, RunnableJob *out_job) -{ -} - -void JobManager::finishBlockIter(uint32_t block_idx) -{ - __syncthreads(); // Ensure entire threadblock has finished - uint32_t num_log_entries = - tbRunData.numLogEntries.load(memory_order::relaxed); - - for (int i = threadIdx.x; i < (int)num_log_entries; - i += mwGPU::consts::numMegakernelThreads) { - uint8_t offset_lp = tbRunData.dataTmpOffsets[i]; - int offset = (int)offset_lp * 8; - - LogEntry *log = (LogEntry *)(tbRunData.dataTmp + offset); - - } - - __syncthreads(); - - - if (threadIdx.x == 0) { - tbRunData.numLogEntries.store(0, memory_order::relaxed); - } -} - -Context::WaveInfo Context::computeWaveInfo() -{ - using namespace mwGPU; - - uint32_t active = __activemask(); - uint32_t num_active = __popc(active); - - uint32_t coalesced_idx = getNumLowerSetBits(active, lane_id_); - - return WaveInfo { - .activeMask = active, - .numActive = num_active, - .coalescedIDX = coalesced_idx, - }; -} - -void Context::stageChildJob(uint32_t func_id, uint32_t num_combined_jobs, - uint32_t bytes_per_job, void *containers) -{ - uint32_t offset = - tbScratch.waitQueue.numWaiting.fetch_add(1, memory_order::relaxed); - - tbScratch.waitingJobs[offset] = Job { - .data = (JobContainerBase *)containers_tmp, - .funcID = func_id, - .numCombinedJobs = num_combined_jobs, - .bytesPerJob = bytes_per_job, - }; -} - -JobID Context::waveSetupNewJob(uint32_t func_id, bool link_parent, - uint32_t num_invocations, uint32_t bytes_per_job, - void **thread_data_store) -{ - auto wave_info = computeWaveInfo(); - auto job_mgr = JobManager::get(); - auto job_trackers = getJobTrackers(job_mgr); - - JobID child_id; - char *tmp_data; - - uint32_t num_total_invocations = - __reduce_add_sync(wave_info.activeMask, num_invocations); - - if (wave_info.isLeader()) { - child_id = allocateJobTrackerSlot(job_mgr, job_trackers); - job_trackers[child_id.id].numOutstanding.store(1, memory_order::relaxed); - job_trackers[child_id.id].remainingInvocations.store( - num_total_invocations, memory_order::relaxed); - - uint32_t total_bytes = wave_info.numActive * bytes_per_job; - tmp_data = (char *)malloc(total_bytes); - - stageChildJob(func_id, tmp_data, bytes_per_job, wave_info.numActive); - } - - child_id = { - .gen = __shfl_sync(wave_info.activeMask, child_id.gen, - wave_info.leaderLane); - .id = __shfl_sync(wave_info.activeMask, child_id.id, - wave_info.leaderLane); - }; - - uint32_t parent_id; - if (link_parent) { - parent_id = job_id_.id; - - uint32_t parent_match = - __match_any_sync(wave_info.activeMask, parent_id); - - uint32_t top_thread = getHighestSetBit(parent_match); - uint32_t num_children = __popc(parent_match); - - if (lane_id_ == top_thread) { - trackers[parent_id].numOutstanding.fetch_add(num_children, - std::memory_order_relaxed); - } - - job_trackers[child_id.id].parents[wave_info.coalescedIDX] = parent_id; - } else { - parent_id = consts::jobTrackerTerm; - } - - tmp_data = (char *)__shfl_sync(wave_info.activeMask, (uintptr_t)tmp_data, - wave_info.leaderLane); - - *thread_data_store = tmp_data + wave_info.coalescedIDX * bytes_per_job; - - return child_id; -} - -JobID Context::getNewJobID(bool link_parent, uint32_t num_invocations) -{ - using namespace mwGPU; - - auto job_mgr = getJobManager(); - JobTracker *trackers = getJobTrackers(job_mgr); - - uint32_t parent_id; - if (link_parent) { - parent_id = job_id_.id; - trackers[parent_id].numOutstanding.fetch_add(1, - std::memory_order_release); - } else { - parent_id = consts::jobTrackerTerm; - } - - return allocateJobTrackerSlot(job_mgr, trackers, parent_id, num_invocations); -} - -// Allocates a shared block of memory for the active threads in wave, -// where lower threads are given a pointer to an early chunk of the block -JobContainerBase * Context::allocJob(uint32_t bytes_per_job, - WaveInfo wave_info) -{ - using namespace mwGPU; - - void *base_store; - if (lane_id_ == wave_info.leaderLane) { - base_store = allocateJobData(bytes_per_job * wave_info.numActive); - } - - // Sync store point & id with wave - base_store = (void *)__shfl_sync(wave_info.activeMask, - (uintptr_t)base_store, wave_info.leaderLane); - - return (JobContainerBase *)( - (char *)base_store + bytes_per_job * wave_info.coalescedIDX); -} - -void Context::logNewJob(uint32_t func_id, JobContainerBase *data, - uint32_t num_invocations, - uint32_t num_bytes_per_job, - uint32_t lane_id, - WaveInfo wave_info) -{ - using namespace mwGPU; - - uint32_t total_num_invocations = - warpSum(wave_info.activeMask, lane_id, num_invocations); - - if (lane_id_ == wave_info.leaderLane) { - queueMultiJobInWaitList(func_id, data, grid_id_, - wave_info.numActive, total_num_invocations, - num_bytes_per_job); - } -} - -void Context::markJobFinished() -{ - using namespace mwGPU; - auto job_mgr = JobManager::get(); - - JobTracker *trackers = getJobTracker(job_mgr); - -#pragma loop unroll - uint32_t job_idx = job_id_.id; - for (int i = 0; i < consts::numWarpThreads; i++) { - uint32_t other_job_idx = __shfl_sync(consts::allActive, job_idx); - - uint32_t job_match = __match_any_sync(wave_info.activeMask, job_idx); - - uint32_t top_thread = getHighestSetBit(job_match); - uint32_t num_invocations = __popc(job_match); - - if (lane_id_ == top_thread) { - uint32_t prev_invocations = - trackers[job_idx].remainingInvocations.fetch_sub(num_invocations, - memory_order::relaxed); - } - - if (prev_invocations == num_invocations) { - decrementJobTracker(trackers, job_id_); - } - } - } - - __syncthreads(); - - uint32_t num_waiting = - tbScratch.waitQueue.numWaiting.load(memory_order::relaxed); - - if (threadIdx.x == 0) { - uint32_t prev_invocations = metadata.remainingInvocations.fetch_sub( - num_invocations); - - if (prev_invocations == num_invocations) { - scratch = - metadata.numStaged.load(memory_order::relaxed); - } else { - scratch[0] = ~0_u32; - } - } - - __syncthreads(); - - uint32_t num_staged = scratch[0]; - if (num_staged == ~0_u32) { - return; - } - - // Merge children jobs - // FIXME: optimization - do a reduction at the thread block level first - // Or, launch a job with num_staged invocations that does this reduction - // globally - - StagedJob *staged_jobs = getStagedJobs(metadata); - - cuda::barrier cpy_barrier; - cuda::barrier::init(&cpy_barrier, consts::numMegakernelThreads); - - for (int i = 0; i < num_staged; i += consts::numMegakernelThreads) { - int offset = i + threadIdx.x; - bool inbounds = offset < num_staged; - offset = inbounds ? offset : num_staged - 1; - - StagedJob &staged_job = staged_jobs[offset]; - uint32_t func_id = staged_job.funcID; - - __syncthreads(); - } - - cpy_barrier.arrive_and_wait(); - - if (threadIdx.x == 0) { - decrementJobTracker(job_mgr, merged_job_id_); - } -} - -static inline uint32_t computeNumAllocatorChunks(uint64_t num_bytes) -{ - return utils::divideRoundUp(num_bytes, - (uint64_t)ChunkAllocator::chunkSize); -} - -} - -extern "C" __global__ void madronaMWGPUComputeConstants( - uint32_t num_worlds, - uint32_t num_world_data_bytes, - uint32_t world_data_alignment, - uint64_t num_allocator_bytes, - madrona::mwGPU::GPUImplConsts *out_constants, - size_t *job_system_buffer_size) -{ - using namespace madrona; - using namespace madrona::mwGPU; - - uint32_t max_num_jobs_per_grid = - madrona::mwGPU::computeMaxNumJobs(num_worlds); - - uint32_t max_num_jobs = consts::numJobGrids * max_num_jobs_per_grid; - - uint64_t total_bytes = sizeof(JobManager); - - uint64_t state_mgr_offset = utils::roundUp(total_bytes, - (uint64_t)alignof(StateManager)); - - total_bytes = state_mgr_offset + sizeof(StateManager); - - uint64_t chunk_allocator_offset = utils::roundUp(total_bytes, - (uint64_t)alignof(ChunkAllocator)); - - total_bytes = chunk_allocator_offset + sizeof(ChunkAllocator); - - uint64_t chunk_base_offset = utils::roundUp(total_bytes, - (uint64_t)alignof(ChunkAllocator::chunkSize)); - - uint64_t num_chunks = computeNumAllocatorChunks(num_allocator_bytes); - total_bytes += num_chunks * ChunkAllocator::chunkSize; - - uint64_t world_data_offset = - utils::roundUp(total_bytes, (uint64_t)world_data_alignment); - - total_bytes = - world_data_offset + (uint64_t)num_world_data_bytes * num_worlds; - - uint64_t grid_offset = utils::roundUp(total_bytes, - (uint64_t)alignof(JobGridInfo)); - - total_bytes = grid_offset + sizeof(JobGridInfo) * consts::numJobGrids; - - uint64_t wait_job_offset = madrona::utils::roundUp(total_bytes, - (uint64_t)alignof(Job)); - - uint64_t num_job_bytes = sizeof(Job) * max_num_jobs; - - total_bytes = wait_job_offset + num_job_bytes; - - uint64_t tracker_offset = madrona::utils::roundUp(total_bytes, - (uint64_t)alignof(JobTracker)); - - // FIXME: using max_num_jobs for this doesn't quite make sense, because - // there will be more outstanding trackers than waiting jobs due to - // parent trackers remaining alive until children finish - total_bytes = tracker_offset + sizeof(JobTracker) * max_num_jobs * 2; - - *out_constants = GPUImplConsts { - .jobSystemAddr = (void *)0ul, - .stateManagerAddr = (void *)state_mgr_offset, - .chunkAllocatorAddr = (void *)chunk_allocator_offset, - .chunkBaseAddr = (void *)chunk_base_offset, - .worldDataAddr = (void *)world_data_offset, - .numWorldDataBytes = num_world_data_bytes, - .numWorlds = num_worlds, - .jobGridsOffset = (uint32_t)grid_offset, - .jobListOffset = (uint32_t)wait_job_offset, - .maxJobsPerGrid = max_num_jobs_per_grid, - .jobTrackerOffset = (uint32_t)tracker_offset, - }; - - *job_system_buffer_size = total_bytes; -} - -extern "C" __global__ void madronaMWGPUInitialize( - uint64_t num_allocator_bytes) -{ - using namespace madrona; - using namespace madrona::mwGPU; - - auto job_mgr = getJobManager(); - - if (threadIdx.x == 0) { - new (job_mgr) JobManager(); - } - __syncthreads(); - - initializeJobSystem(job_mgr); - - if (threadIdx.x == 0) { - new (GPUImplConsts::get().stateManagerAddr) StateManager(1024); - } - - if (threadIdx.x == 0) { - new (GPUImplConsts::get().chunkAllocatorAddr) ChunkAllocator( - computeNumAllocatorChunks(num_allocator_bytes)); - } -} - -extern "C" __global__ void madronaTrainJobSystemKernel() -{ - madrona::mwGPU::jobLoop(); -} diff --git a/src/mw/device/megakernel_job_impl.inl b/src/mw/device/megakernel_job_impl.inl deleted file mode 100644 index ce18872c..00000000 --- a/src/mw/device/megakernel_job_impl.inl +++ /dev/null @@ -1,62 +0,0 @@ -#pragma once -#include -#include - -namespace madrona { -namespace mwGPU { - -#ifdef MADRONA_CLANG -#pragma clang diagnostic push -#pragma clang diagnostic ignored "-Wundefined-internal" -#endif -static inline __attribute__((always_inline)) void dispatch( - uint32_t func_id, - madrona::JobContainerBase *data, - uint32_t *data_indices, - uint32_t *invocation_offsets, - uint32_t num_launches, - uint32_t grid); -#ifdef MADRONA_CLANG -#pragma clang diagnostic pop -#endif - -static inline __attribute__((always_inline)) void megakernelImpl() -{ - static __shared__ RunnableJob threadblock_job; - static __shared__ bool job_found; - - while (true) { - { - JobManager *job_mgr = JobManager::get(); - if (job_mgr->numOutstandingInvocations.load( - std::memory_order_relaxed) == 0) { - break; - } - - if (threadIdx.x == 0) { - job_found = job_mgr->startBlockIter(&threadblock_job); - } - __syncthreads(); - - if (!job_found) { - break; - } - } - - RunnableJob runnable = threadblock_job; - - dispatch(func_id, data, data_indices, invocation_offsets, num_launches, - grid); - - // Iterate through submitted jobs, merge job IDs, etc - JobManager::get()->finishBlockIter(); - } -} - -} -} - -extern "C" void madronaMWGPUMegakernel() -{ - madrona::mwGPU::megakernelImpl(); -} diff --git a/src/mw/device/work_queue.hpp b/src/mw/device/work_queue.hpp deleted file mode 100644 index fc6af2f4..00000000 --- a/src/mw/device/work_queue.hpp +++ /dev/null @@ -1,42 +0,0 @@ -#pragma once - -#include -#include - -#include - -struct WorkItem { - uint32_t job_id; - void *data; -}; - -template -class WorkQueue { -public: - inline void enqueue(WorkItem work_item) - { - do { - uint32_t cur_count = count_.load_relaxed(); - if (cur_count >= queue_size) { - continue; - } - - uint32_t cur_head = head_.load_relaxed(); - uint32_t cur_tail = tail_.load_relaxed(); - } while(true); - while (!ensureEnqueue()) { - - - } - } - -private: - MADRONA_ALWAYS_INLINE bool ensureEnqueue() - { - } - - std::array tickets_; - AtomicU32 head_; - AtomicU32 tail_; - AtomicI32 count_; -}; diff --git a/src/mw/functionality.cpp b/src/mw/functionality.cpp deleted file mode 100644 index 4ad54f1c..00000000 --- a/src/mw/functionality.cpp +++ /dev/null @@ -1,15 +0,0 @@ -/* - * Copyright 2021-2022 Brennan Shacklett and contributors - * - * Use of this source code is governed by an MIT-style - * license that can be found in the LICENSE file or at - * https://opensource.org/licenses/MIT. - */ -#include - -#include "job.hpp" - -void set_val(float *data, uint32_t idx, float v) -{ - data[idx] = v; -} From 24e51085b4ccb7ad60ba9c67da264ba607b2e68a Mon Sep 17 00:00:00 2001 From: Alexis Duburcq Date: Thu, 23 Jul 2026 13:27:31 +0200 Subject: [PATCH 09/14] Strip JAX, TrainInterface, CudaSync and unused tensor exports Genesis only calls MadronaBatchRenderer.{init,render,rgb/depth/ segmentation/normal_tensor} and consumes the results via Tensor.to_torch. Remove the JAX interface (jax_register.py, JAXInterface, to_jax), the TrainInterface/CudaSync/PyExecMode Python classes, the Tensor ndarray constructor, and the four unused instance/camera tensor exports. --- include/madrona/py/bindings.hpp | 38 ---- include/madrona/py/bindings.inl | 123 ----------- include/madrona/py/utils.hpp | 100 --------- src/bridge/bindings.cpp | 4 - src/bridge/mgr.cpp | 44 ---- src/bridge/mgr.hpp | 5 - src/python/bindings.cpp | 315 ---------------------------- src/python/jax_register.py | 350 -------------------------------- src/python/utils.cpp | 332 ------------------------------ 9 files changed, 1311 deletions(-) delete mode 100644 include/madrona/py/bindings.inl delete mode 100644 src/python/jax_register.py diff --git a/include/madrona/py/bindings.hpp b/include/madrona/py/bindings.hpp index 9584a259..9c09adb6 100644 --- a/include/madrona/py/bindings.hpp +++ b/include/madrona/py/bindings.hpp @@ -16,44 +16,6 @@ namespace nb = nanobind; namespace madrona::py { -class JAXInterface { -public: - // Returns a function that registers custom_call_name with XLA - // to call step_fn (or async_step_fn in GPU mode) and returns - // the python function implementing the custom call. - template - static auto buildEntry(); - -private: - template - static void cpuEntryFn(void **out, void **in); - -#ifdef MADRONA_CUDA_SUPPORT - template - static void gpuEntryFn(cudaStream_t strm, void **buffers, - const char *opaque, size_t opaque_len); -#endif - - static nb::dict setup(const TrainInterface &iface, - nb::object sim_obj, - void *sim_ptr, - void *init_fn, - void *step_fn, - void *save_ckpts_fn, - void *restore_ckpts_fn, - bool xla_gpu); -}; - void setupMadronaSubmodule(nb::module_ parent_mod); } - -#include "bindings.inl" diff --git a/include/madrona/py/bindings.inl b/include/madrona/py/bindings.inl deleted file mode 100644 index c10dc784..00000000 --- a/include/madrona/py/bindings.inl +++ /dev/null @@ -1,123 +0,0 @@ -#include - -#include -#include -#include - -namespace madrona::py { - -template -auto JAXInterface::buildEntry() -{ - using SimT = - typename utils::ExtractClassFromMemberPtr::type; - - return [](nb::object sim, bool xla_gpu) { - void *init_fn; - void *step_fn; - void *save_ckpts_fn; - void *restore_ckpts_fn; - if (xla_gpu) { -#ifdef MADRONA_CUDA_SUPPORT - if constexpr (gpu_init_fn != nullptr && - gpu_step_fn != nullptr) { - auto init_wrapper = - &JAXInterface::gpuEntryFn; - init_fn = std::bit_cast(init_wrapper); - - auto step_wrapper = - &JAXInterface::gpuEntryFn; - step_fn = std::bit_cast(step_wrapper); - } else { - init_fn = nullptr; - step_fn = nullptr; - } - - if constexpr (gpu_save_ckpts_fn != nullptr && - gpu_restore_ckpts_fn != nullptr) { - auto gpu_save_ckpts_wrapper = - &JAXInterface::gpuEntryFn< - SimT, gpu_save_ckpts_fn>; - save_ckpts_fn = std::bit_cast( - gpu_save_ckpts_wrapper); - - auto gpu_restore_ckpts_wrapper = - &JAXInterface::gpuEntryFn< - SimT, gpu_restore_ckpts_fn>; - restore_ckpts_fn = std::bit_cast( - gpu_restore_ckpts_wrapper); - } else { - save_ckpts_fn = nullptr; - restore_ckpts_fn = nullptr; - } -#else - init_fn = nullptr; - step_fn = nullptr; - save_ckpts_fn = nullptr; - restore_ckpts_fn = nullptr; -#endif - } else { - auto init_wrapper = - &JAXInterface::cpuEntryFn; - init_fn = std::bit_cast(init_wrapper); - - auto step_wrapper = - &JAXInterface::cpuEntryFn; - step_fn = std::bit_cast(step_wrapper); - - if constexpr (cpu_save_ckpts_fn != nullptr && - cpu_restore_ckpts_fn != nullptr) { - auto cpu_save_ckpts_wrapper = - &JAXInterface::cpuEntryFn< - SimT, cpu_save_ckpts_fn>; - save_ckpts_fn = std::bit_cast( - cpu_save_ckpts_wrapper); - - auto cpu_restore_ckpts_wrapper = - &JAXInterface::cpuEntryFn< - SimT, cpu_restore_ckpts_fn>; - restore_ckpts_fn = std::bit_cast( - cpu_restore_ckpts_wrapper); - } else { - save_ckpts_fn = nullptr; - restore_ckpts_fn = nullptr; - } - } - assert(init_fn != nullptr && step_fn != nullptr); - - SimT *sim_ptr = nb::inst_ptr(sim); - TrainInterface iface = std::invoke(iface_fn, *sim_ptr); - return setup(iface, sim, (void *)sim_ptr, init_fn, step_fn, - save_ckpts_fn, restore_ckpts_fn, xla_gpu); - }; -} - -template -void JAXInterface::cpuEntryFn(void **out, void **in) -{ - SimT *sim = *(SimT **)in[0]; - std::invoke(fn, *sim, in + 2, out); -} - -#ifdef MADRONA_CUDA_SUPPORT -template -void JAXInterface::gpuEntryFn(cudaStream_t strm, void **buffers, - const char *opaque, size_t) -{ - SimT *sim = *(SimT **)opaque; - - // The first buffer entry is used by the CPU backend, skip - // The scond buffer entry is a token JAX uses for ordering, skip - std::invoke(fn, *sim, strm, buffers + 2); -} -#endif - -} diff --git a/include/madrona/py/utils.hpp b/include/madrona/py/utils.hpp index 9305528a..91a02071 100644 --- a/include/madrona/py/utils.hpp +++ b/include/madrona/py/utils.hpp @@ -16,45 +16,6 @@ namespace madrona::py { -#ifdef MADRONA_CUDA_SUPPORT -class CudaSync final { -public: - CudaSync(cudaExternalSemaphore_t sema); - void wait(uint64_t strm); - -private: -#ifdef MADRONA_LINUX - // These classes have to be virtual on linux so a unique typeinfo - // is emitted. Otherwise every user of this class gets a weak symbol - // reference and nanobind can't map the types correctly - virtual void key_(); -#endif - - cudaExternalSemaphore_t sema_; -}; -#endif - -// Need to wrap the actual enum class because macos -// RTTI for enum classes isn't consistent across libraries -class PyExecMode final { -public: - inline PyExecMode(ExecMode v) - : v_(v) - {} - - inline operator ExecMode() const - { - return v_; - } - -private: -#ifdef MADRONA_LINUX - virtual void key_(); -#endif - - ExecMode v_; -}; - enum class TensorElementType { UInt8, Int8, @@ -142,65 +103,4 @@ class Tensor final { std::array dimensions_; }; -struct NamedTensor { - const char *name; - Tensor tensor; -}; - -struct TrainStepInputInterface { - Span actions; - Tensor resets; - Tensor simCtrl; - Span pbt = {}; -}; - -struct TrainStepOutputInterface { - Span observations; - Tensor rewards; - Tensor dones; - Span stats = {}; - Span pbt = {}; -}; - -struct TrainCheckpointingInterface { - Tensor checkpointData; -}; - -class TrainInterface { -public: - TrainInterface(); - TrainInterface(TrainStepInputInterface step_inputs, - TrainStepOutputInterface step_outputs, - Optional checkpointing = - Optional::none()); - TrainInterface(TrainInterface &&o); - ~TrainInterface(); - - TrainInterface & operator=(TrainInterface &&o); - - TrainStepInputInterface stepInputs() const; - TrainStepOutputInterface stepOutputs() const; - Optional checkpointing() const; - - void cpuCopyStepInputs(void **buffers); - void cpuCopyObservations(void **buffers); - void cpuCopyStepOutputs(void **buffers); - -#ifdef MADRONA_CUDA_SUPPORT - void ** cudaCopyStepInputs(cudaStream_t strm, void **buffers); - void cudaCopyObservations(cudaStream_t strm, void **buffers); - void cudaCopyStepOutputs(cudaStream_t strm, void **buffers); -#endif - -private: - struct Impl; - -#ifdef MADRONA_LINUX - virtual void key_(); -#endif - - std::unique_ptr impl_; -}; - - } diff --git a/src/bridge/bindings.cpp b/src/bridge/bindings.cpp index afc702cb..89f14825 100644 --- a/src/bridge/bindings.cpp +++ b/src/bridge/bindings.cpp @@ -205,10 +205,6 @@ NB_MODULE(_gs_madrona_batch_renderer, m) { reinterpret_cast(render_options.data()) ); }) - .def("instance_positions_tensor", &Manager::instancePositionsTensor) - .def("instance_rotations_tensor", &Manager::instanceRotationsTensor) - .def("camera_positions_tensor", &Manager::cameraPositionsTensor) - .def("camera_rotations_tensor", &Manager::cameraRotationsTensor) .def("rgb_tensor", &Manager::rgbTensor) .def("depth_tensor", &Manager::depthTensor) .def("normal_tensor", &Manager::normalTensor) diff --git a/src/bridge/mgr.cpp b/src/bridge/mgr.cpp index 00bd9ca9..03295bba 100644 --- a/src/bridge/mgr.cpp +++ b/src/bridge/mgr.cpp @@ -823,50 +823,6 @@ void Manager::render(const math::Vector3 *geom_pos, const math::Quat *geom_rot, impl_->render(geom_pos, geom_rot, cam_pos, cam_rot, render_options); } -Tensor Manager::instancePositionsTensor() const -{ - return impl_->exportTensor(ExportID::InstancePositions, - TensorElementType::Float32, - { - impl_->cfg.numWorlds, - impl_->numGeoms, - sizeof(Vector3) / sizeof(float), - }); -} - -Tensor Manager::instanceRotationsTensor() const -{ - return impl_->exportTensor(ExportID::InstanceRotations, - TensorElementType::Float32, - { - impl_->cfg.numWorlds, - impl_->numGeoms, - sizeof(Quat) / sizeof(float), - }); -} - -Tensor Manager::cameraPositionsTensor() const -{ - return impl_->exportTensor(ExportID::CameraPositions, - TensorElementType::Float32, - { - impl_->cfg.numWorlds, - impl_->numCams, - sizeof(Vector3) / sizeof(float), - }); -} - -Tensor Manager::cameraRotationsTensor() const -{ - return impl_->exportTensor(ExportID::CameraRotations, - TensorElementType::Float32, - { - impl_->cfg.numWorlds, - impl_->numCams, - sizeof(Quat) / sizeof(float), - }); -} - Tensor Manager::rgbTensor() const { const uint8_t *rgb_ptr = impl_->getRGBOut(); diff --git a/src/bridge/mgr.hpp b/src/bridge/mgr.hpp index fed8c015..db4d36be 100644 --- a/src/bridge/mgr.hpp +++ b/src/bridge/mgr.hpp @@ -116,11 +116,6 @@ class Manager { // These functions export Tensor objects that link the ECS // simulation state to the python bindings / PyTorch tensors (src/bindings.cpp) // - MGR_EXPORT madrona::py::Tensor instancePositionsTensor() const; - MGR_EXPORT madrona::py::Tensor instanceRotationsTensor() const; - MGR_EXPORT madrona::py::Tensor cameraPositionsTensor() const; - MGR_EXPORT madrona::py::Tensor cameraRotationsTensor() const; - MGR_EXPORT madrona::py::Tensor rgbTensor() const; MGR_EXPORT madrona::py::Tensor depthTensor() const; MGR_EXPORT madrona::py::Tensor normalTensor() const; diff --git a/src/python/bindings.cpp b/src/python/bindings.cpp index 049b389a..f6460c11 100644 --- a/src/python/bindings.cpp +++ b/src/python/bindings.cpp @@ -1,29 +1,12 @@ #include #include -#include - namespace nb = nanobind; namespace madrona::py { namespace { -struct JAXModule { - nb::object mod; - nb::object typeU8; - nb::object typeI8; - nb::object typeI16; - nb::object typeI32; - nb::object typeI64; - nb::object typeF16; - nb::object typeF32; - nb::object typeShapeDtypeStruct; - - static inline JAXModule imp(); - inline nb::object getDType(TensorElementType type) const; -}; - nb::dlpack::dtype toDLPackType(TensorElementType type) { switch (type) { @@ -71,312 +54,14 @@ nb::object tensor_to_pytorch(const Tensor &tensor) )); } -auto tensor_to_jax(const Tensor &tensor) -{ - nb::dlpack::dtype type = toDLPackType(tensor.type()); - - return nb::ndarray( - tensor.devicePtr(), - (size_t)tensor.numDims(), - (const size_t *)tensor.dims(), - nb::handle(), - nullptr, - type, - tensor.isOnGPU() ? - nb::device::cuda::value : - nb::device::cpu::value, - tensor.isOnGPU() ? tensor.gpuID() : 0 - ); -} - - -JAXModule JAXModule::imp() -{ - nb::object mod = nb::module_::import_("jax"); - nb::object jnp = mod.attr("numpy"); - - nb::object type_u8 = jnp.attr("uint8"); - nb::object type_i8 = jnp.attr("int8"); - nb::object type_i16 = jnp.attr("int16"); - nb::object type_i32 = jnp.attr("int32"); - nb::object type_i64 = jnp.attr("int64"); - nb::object type_f16 = jnp.attr("float16"); - nb::object type_f32 = jnp.attr("float32"); - - nb::object type_shapedtype = mod.attr("ShapeDtypeStruct"); - - return JAXModule { - .mod = mod, - .typeU8 = type_u8, - .typeI8 = type_i8, - .typeI16 = type_i16, - .typeI32 = type_i32, - .typeI64 = type_i64, - .typeF16 = type_f16, - .typeF32 = type_f32, - .typeShapeDtypeStruct = type_shapedtype, - }; -} - -nb::object JAXModule::getDType(TensorElementType type) const -{ - switch (type) { - case TensorElementType::UInt8: - return typeU8; - case TensorElementType::Int8: - return typeI8; - case TensorElementType::Int16: - return typeI16; - case TensorElementType::Int32: - return typeI32; - case TensorElementType::Int64: - return typeI64; - case TensorElementType::Float16: - return typeF16; - case TensorElementType::Float32: - return typeF32; - default: MADRONA_UNREACHABLE(); - } -} - -nb::object tensor_to_jax_shape_dtype( - const JAXModule &jax_mod, - const Tensor &t) -{ - nb::list shape; - - for (int64_t i = 0; i < t.numDims(); i++) { - int64_t d = t.dims()[i]; - shape.append(d); - } - - return jax_mod.typeShapeDtypeStruct( - nb::arg("dtype") = jax_mod.getDType(t.type()), - nb::arg("shape") = shape); -} - -nb::dict train_interface_inputs_to_pytree( - const JAXModule &jax_mod, - const TrainInterface &iface) -{ - const TrainStepInputInterface &inputs = iface.stepInputs(); - - nb::dict d; - - nb::dict actions; - for (const NamedTensor &t : inputs.actions) { - actions[t.name] = tensor_to_jax_shape_dtype(jax_mod, t.tensor); - } - - d["actions"] = actions; - - d["resets"] = tensor_to_jax_shape_dtype(jax_mod, inputs.resets); - d["sim_ctrl"] = tensor_to_jax_shape_dtype(jax_mod, inputs.simCtrl); - - nb::dict pbt; - for (const NamedTensor &t : inputs.pbt) { - pbt[t.name] = tensor_to_jax_shape_dtype(jax_mod, t.tensor); - } - - d["pbt"] = pbt; - - return d; -} - -nb::dict train_interface_outputs_to_pytree( - const JAXModule &jax_mod, - const TrainInterface &iface) -{ - const TrainStepOutputInterface &outputs = iface.stepOutputs(); - - nb::dict d; - - nb::dict obs; - for (const NamedTensor &t : outputs.observations) { - obs[t.name] = tensor_to_jax_shape_dtype(jax_mod, t.tensor); - } - - d["obs"] = obs; - - d["rewards"] = tensor_to_jax_shape_dtype(jax_mod, outputs.rewards); - d["dones"] = tensor_to_jax_shape_dtype(jax_mod, outputs.dones); - - nb::dict pbt; - for (const NamedTensor &t : outputs.pbt) { - pbt[t.name] = tensor_to_jax_shape_dtype(jax_mod, t.tensor); - } - - d["pbt"] = pbt; - - return d; -} - -nb::dict train_interface_checkpointing_to_pytree( - const JAXModule &jax_mod, - const TrainInterface &iface) -{ - nb::dict d; - - d["data"] = tensor_to_jax_shape_dtype( - jax_mod, iface.checkpointing()->checkpointData); - - return d; -} - -} - -nb::dict JAXInterface::setup(const TrainInterface &iface, - nb::object sim_obj, - void *sim_ptr, - void *init_fn, - void *step_fn, - void *save_ckpts_fn, - void *restore_ckpts_fn, - bool xla_gpu) -{ - JAXModule jax_mod = JAXModule::imp(); - - nb::capsule init_fn_capsule(init_fn, "xla._CUSTOM_CALL_TARGET"); - nb::capsule step_fn_capsule(step_fn, "xla._CUSTOM_CALL_TARGET"); - - auto sim_encode = nb::bytes((char *)&sim_ptr, sizeof(char *)); - - nb::dict input_iface = train_interface_inputs_to_pytree(jax_mod, iface); - nb::dict output_iface = train_interface_outputs_to_pytree(jax_mod, iface); - - nb::dict scope; - scope["sim_obj"] = sim_obj; - scope["sim_ptr"] = (uint64_t)sim_ptr; - scope["sim_encode"] = sim_encode; - scope["step_inputs_iface"] = input_iface; - scope["step_outputs_iface"] = output_iface; - scope["init_custom_call_capsule"] = init_fn_capsule; - scope["step_custom_call_capsule"] = step_fn_capsule; - scope["custom_call_platform"] = xla_gpu ? "gpu" : "cpu"; - - if (iface.checkpointing().has_value()) { - assert(save_ckpts_fn != nullptr && restore_ckpts_fn != nullptr); - - nb::dict checkpointing_iface = train_interface_checkpointing_to_pytree( - jax_mod, iface); - - nb::capsule save_ckpts_fn_capsule( - save_ckpts_fn, "xla._CUSTOM_CALL_TARGET"); - nb::capsule restore_ckpts_fn_capsule( - restore_ckpts_fn, "xla._CUSTOM_CALL_TARGET"); - - scope["ckpt_iface"] = checkpointing_iface; - scope["save_ckpts_custom_call_capsule"] = save_ckpts_fn_capsule; - scope["restore_ckpts_custom_call_capsule"] = restore_ckpts_fn_capsule; - } else { - scope["ckpt_iface"] = nb::none(); - } - - nb::exec( -#include "jax_register.py" - , scope); - - nb::dict fns; - fns["init"] = scope["init_func"]; - fns["step"] = scope["step_func"]; - - if (iface.checkpointing().has_value()) { - fns["save_ckpts"] = scope["save_ckpts_func"]; - fns["restore_ckpts"] = scope["restore_ckpts_func"]; - } - - return fns; -} - -static TensorElementType fromDLPackType(nb::dlpack::dtype dtype) -{ - using ET = TensorElementType; - - if (nb::dlpack::dtype_code(dtype.code) == nb::dlpack::dtype_code::Int) { - switch (dtype.bits) { - case 8: - return ET::Int8; - case 16: - return ET::Int16; - case 32: - return ET::Int32; - case 64: - return ET::Int64; - default: - break; - } - } else if (nb::dlpack::dtype_code(dtype.code) == - nb::dlpack::dtype_code::UInt && dtype.bits == 8) { - return ET::UInt8; - } else if (nb::dlpack::dtype_code(dtype.code) == - nb::dlpack::dtype_code::Float) { - if (dtype.bits == 16) { - return ET::Float16; - } - if (dtype.bits == 32) { - return ET::Float32; - } - } - - FATAL("Tensor: Invalid tensor dtype"); } void setupMadronaSubmodule(nb::module_ parent_mod) { auto m = parent_mod.def_submodule("madrona"); - nb::class_(m, "ExecMode") - .def_prop_ro_static("CPU", [](nb::handle) { - return madrona::py::PyExecMode(madrona::ExecMode::CPU); - }) - .def_prop_ro_static("CUDA", [](nb::handle) { - return madrona::py::PyExecMode(madrona::ExecMode::CUDA); - }); - nb::class_(m, "Tensor") - .def("__init__", [](Tensor *dst, nb::ndarray<> torch_tensor) { - Optional gpu_id = Optional::none(); - if (torch_tensor.device_type() == nb::device::cuda::value) { - gpu_id = torch_tensor.device_id(); - } else if (torch_tensor.device_type() != nb::device::cpu::value) { - FATAL("madrona::Tensor: failed to import unknown tensor type"); - } - - std::array dims; - - if (torch_tensor.ndim() > Tensor::maxDimensions) { - FATAL("Cannot construct Tensor with more than %ld dimensions\n", - Tensor::maxDimensions); - } - - for (size_t i = 0; i < torch_tensor.ndim(); i++) { - dims[i] = torch_tensor.shape(i); - } - - new (dst) Tensor(torch_tensor.data(), - fromDLPackType(torch_tensor.dtype()), - { dims.data(), (CountT)torch_tensor.ndim() }, - gpu_id); - }) .def("to_torch", tensor_to_pytorch, nb::rv_policy::automatic_reference) - .def("to_jax", tensor_to_jax, nb::rv_policy::automatic_reference) - ; - -#ifdef MADRONA_CUDA_SUPPORT - nb::class_(m, "CudaSync") - .def("wait", &CudaSync::wait) - ; -#endif - - nb::class_(m, "TrainInterface") - .def("step_inputs", [](const TrainInterface &iface) { - return train_interface_inputs_to_pytree( - JAXModule::imp(), iface); - }) - .def("step_outputs", [](const TrainInterface &iface) { - return train_interface_outputs_to_pytree( - JAXModule::imp(), iface); - }) ; } diff --git a/src/python/jax_register.py b/src/python/jax_register.py deleted file mode 100644 index f50d6a27..00000000 --- a/src/python/jax_register.py +++ /dev/null @@ -1,350 +0,0 @@ -R"===(#" - -from functools import partial - -import jax -from jax import numpy as jnp -from jax import core, dtypes -from jax.core import ShapedArray -from jax.lib import xla_client -from jax.interpreters import xla -from jax.interpreters import mlir -from jax.interpreters.mlir import ir, dtype_to_ir_type -from jaxlib.hlo_helpers import custom_call -import builtins as __builtins__ -import numpy as np - -from jax._src import effects -from jax._src.lib.mlir.dialects import hlo - -custom_call_prefix = f"{type(sim_obj).__name__}_{id(sim_obj)}" -init_custom_call_name = f"{custom_call_prefix}_init" -step_custom_call_name = f"{custom_call_prefix}_step" -del sim_obj - -xla_client.register_custom_call_target(init_custom_call_name, init_custom_call_capsule, platform=custom_call_platform) - -xla_client.register_custom_call_target(step_custom_call_name, step_custom_call_capsule, platform=custom_call_platform) - - -def _row_major_layout(shape): - return tuple(range(len(shape) - 1, -1, -1)) - - -def _shape_dtype_to_abstract_vals(vs): - return tuple(ShapedArray(v.shape, v.dtype) for v in vs) - - -def _lower_shape_dtypes(shape_dtypes): - return [ir.RankedTensorType.get(i.shape, dtype_to_ir_type(i.dtype)) for i in shape_dtypes] - - -# Below code uses ordered effects, which is internal logic taken from -# jax io_callback code and emit_python_callback code. The idea is a -# token is threaded through the custom_call, which preserves ordering and -# prevents sim_step calls from being elided if their outputs aren't used. -# This code deviates slightly from the jax convention which is to put -# the token in the first input / output on GPU. Instead, we put the token -# in the first input and the *last* output, which means we can just skip -# the first buffer passed to the custom call target (the input token) -# and write to the rest of the buffers normally, leaving the final token -# output buffer untouched. - - -def _fake_token_type(): - return ir.RankedTensorType.get((0,), dtype_to_ir_type(np.dtype("float32"))) - - -def _prepend_token_to_inputs(types, layouts): - return [_fake_token_type(), *types], [(0,), *layouts] - - -def _append_token_to_results(types, layouts): - return [*types, _fake_token_type()], [*layouts, (0,)] - - -def _init_lowering(ctx): - token = mlir.ir_constant(np.empty((0,), np.float32)) - - result_types = _lower_shape_dtypes(step_outputs_iface["obs"].values()) - result_layouts = [_row_major_layout(t.shape) for t in result_types] - - result_types, result_layouts = _append_token_to_results(result_types, result_layouts) - - results = custom_call( - init_custom_call_name, - backend_config=sim_encode, - operands=[mlir.ir_constant(sim_ptr), token], - operand_layouts=[(), (0,)], - result_types=result_types, - result_layouts=result_layouts, - has_side_effect=True, - ).results - - *results, token = results - return token, *results - - -def _init_abstract(): - return ( - ShapedArray((0,), jnp.float32), - *_shape_dtype_to_abstract_vals(step_outputs_iface["obs"].values()), - ) - - -def _flatten_step_output_shape_dtypes(): - result_shape_dtypes = list(step_outputs_iface["obs"].values()) - - result_shape_dtypes.append(step_outputs_iface["rewards"]) - result_shape_dtypes.append(step_outputs_iface["dones"]) - - result_shape_dtypes += step_outputs_iface["pbt"].values() - - return result_shape_dtypes - - -def _step_lowering(ctx, *flattened_inputs): - token, *flattened_inputs = flattened_inputs - - input_types = [ir.RankedTensorType(i.type) for i in flattened_inputs] - input_layouts = [_row_major_layout(t.shape) for t in input_types] - input_types, input_layouts = _prepend_token_to_inputs(input_types, input_layouts) - - result_types = _lower_shape_dtypes(_flatten_step_output_shape_dtypes()) - result_layouts = [_row_major_layout(t.shape) for t in result_types] - result_types, result_layouts = _append_token_to_results(result_types, result_layouts) - - inputs = [token, *flattened_inputs] - - results = custom_call( - step_custom_call_name, - backend_config=sim_encode, - operands=[mlir.ir_constant(sim_ptr), *inputs], - operand_layouts=[(), *input_layouts], - result_types=result_types, - result_layouts=result_layouts, - has_side_effect=True, - ).results - - *results, token = results - return token, *results - - -def _step_abstract(*inputs): - return ( - ShapedArray((0,), jnp.float32), - *_shape_dtype_to_abstract_vals(_flatten_step_output_shape_dtypes()), - ) - - -_init_primitive = core.Primitive(init_custom_call_name) -_init_primitive.multiple_results = True -_init_primitive.def_impl(partial(xla.apply_primitive, _init_primitive)) -_init_primitive.def_abstract_eval(_init_abstract) - -mlir.register_lowering( - _init_primitive, - _init_lowering, - platform=custom_call_platform, -) - -_step_primitive = core.Primitive(step_custom_call_name) -_step_primitive.multiple_results = True -_step_primitive.def_impl(partial(xla.apply_primitive, _step_primitive)) -_step_primitive.def_abstract_eval(_step_abstract) - -mlir.register_lowering( - _step_primitive, - _step_lowering, - platform=custom_call_platform, -) - - -def init_func(): - sim_state, *flattened_out = _init_primitive.bind() - return { - "state": sim_state, - "obs": {k: o for k, o in zip(step_outputs_iface["obs"].keys(), flattened_out)}, - } - - -def step_func(step_inputs): - flattened_in = [step_inputs["state"]] - - for k in step_inputs_iface["actions"].keys(): - print(step_inputs["actions"]) - flattened_in.append(step_inputs["actions"][k]) - - flattened_in.append(step_inputs["resets"]) - flattened_in.append(step_inputs["sim_ctrl"]) - - for k in step_inputs_iface["pbt"].keys(): - flattened_in.append(step_inputs["pbt"][k]) - - sim_state, *flattened_out = _step_primitive.bind(*flattened_in) - - out = {} - - cur_idx = 0 - - def next_out(): - nonlocal cur_idx - o = flattened_out[cur_idx] - cur_idx += 1 - return o - - out["state"] = sim_state - out["obs"] = {} - for k in step_outputs_iface["obs"].keys(): - out["obs"][k] = next_out() - - out["rewards"] = next_out() - out["dones"] = next_out() - - out["pbt"] = {} - for k in step_outputs_iface["pbt"].keys(): - out["pbt"][k] = next_out() - - return out - - -init_func = jax.jit(init_func) -step_func = jax.jit(step_func) - -if ckpt_iface != None: - save_ckpts_custom_call_name = f"{custom_call_prefix}_save_ckpts" - restore_ckpts_custom_call_name = f"{custom_call_prefix}_restore_ckpts" - - xla_client.register_custom_call_target( - save_ckpts_custom_call_name, - save_ckpts_custom_call_capsule, - platform=custom_call_platform, - ) - - xla_client.register_custom_call_target( - restore_ckpts_custom_call_name, - restore_ckpts_custom_call_capsule, - platform=custom_call_platform, - ) - - def _flatten_save_ckpts_output_shape_dtypes(): - result_shape_dtypes = [ckpt_iface["data"]] - return result_shape_dtypes - - def _save_ckpts_lowering(ctx, *flattened_inputs): - token, *flattened_inputs = flattened_inputs - - input_types = [ir.RankedTensorType(i.type) for i in flattened_inputs] - input_layouts = [_row_major_layout(t.shape) for t in input_types] - input_types, input_layouts = _prepend_token_to_inputs(input_types, input_layouts) - - result_types = _lower_shape_dtypes(_flatten_save_ckpts_output_shape_dtypes()) - result_layouts = [_row_major_layout(t.shape) for t in result_types] - result_types, result_layouts = _append_token_to_results(result_types, result_layouts) - - inputs = [token, *flattened_inputs] - - results = custom_call( - save_ckpts_custom_call_name, - backend_config=sim_encode, - operands=[mlir.ir_constant(sim_ptr), *inputs], - operand_layouts=[(), *input_layouts], - result_types=result_types, - result_layouts=result_layouts, - has_side_effect=True, - ).results - - *results, token = results - return token, *results - - def _save_ckpts_abstract(*inputs): - return ( - core.abstract_token, - *_shape_dtype_to_abstract_vals(_flatten_save_ckpts_output_shape_dtypes()), - ) - - _save_ckpts_primitive = core.Primitive(save_ckpts_custom_call_name) - _save_ckpts_primitive.multiple_results = True - _save_ckpts_primitive.def_impl(partial(xla.apply_primitive, _save_ckpts_primitive)) - _save_ckpts_primitive.def_abstract_eval(_save_ckpts_abstract) - - mlir.register_lowering( - _save_ckpts_primitive, - _save_ckpts_lowering, - platform=custom_call_platform, - ) - - def _flatten_restore_ckpts_output_shape_dtypes(): - result_shape_dtypes = list(step_outputs_iface["obs"].values()) - return result_shape_dtypes - - def _restore_ckpts_lowering(ctx, *flattened_inputs): - token, *flattened_inputs = flattened_inputs - - input_types = [ir.RankedTensorType(i.type) for i in flattened_inputs] - input_layouts = [_row_major_layout(t.shape) for t in input_types] - input_types, input_layouts = _prepend_token_to_inputs(input_types, input_layouts) - - result_types = _lower_shape_dtypes(_flatten_restore_ckpts_output_shape_dtypes()) - result_layouts = [_row_major_layout(t.shape) for t in result_types] - result_types, result_layouts = _append_token_to_results(result_types, result_layouts) - - inputs = [token, *flattened_inputs] - - results = custom_call( - restore_ckpts_custom_call_name, - backend_config=sim_encode, - operands=[mlir.ir_constant(sim_ptr), *inputs], - operand_layouts=[(), *input_layouts], - result_types=result_types, - result_layouts=result_layouts, - has_side_effect=True, - ).results - - *results, token = results - return token, *results - - def _restore_ckpts_abstract(*inputs): - return ( - core.abstract_token, - *_shape_dtype_to_abstract_vals(_flatten_restore_ckpts_output_shape_dtypes()), - ) - - _restore_ckpts_primitive = core.Primitive(restore_ckpts_custom_call_name) - _restore_ckpts_primitive.multiple_results = True - _restore_ckpts_primitive.def_impl(partial(xla.apply_primitive, _restore_ckpts_primitive)) - _restore_ckpts_primitive.def_abstract_eval(_restore_ckpts_abstract) - - mlir.register_lowering( - _restore_ckpts_primitive, - _restore_ckpts_lowering, - platform=custom_call_platform, - ) - - def save_ckpts_func(save_inputs): - flattened_in = [save_inputs["state"]] - flattened_in.append(save_inputs["should_save"]) - - sim_state, *flattened_out = _save_ckpts_primitive.bind(*flattened_in) - - return { - "state": sim_state, - "ckpts": flattened_out[0], - } - - def restore_ckpts_func(restore_inputs): - flattened_in = [restore_inputs["state"]] - flattened_in.append(restore_inputs["should_restore"]) - flattened_in.append(restore_inputs["ckpt_data"]) - - sim_state, *flattened_out = _restore_ckpts_primitive.bind(*flattened_in) - - return { - "state": sim_state, - "obs": {k: o for k, o in zip(step_outputs_iface["obs"].keys(), flattened_out)}, - } - - save_ckpts_func = jax.jit(save_ckpts_func) - restore_ckpts_func = jax.jit(restore_ckpts_func) - -# )===" diff --git a/src/python/utils.cpp b/src/python/utils.cpp index a8409465..a105b501 100644 --- a/src/python/utils.cpp +++ b/src/python/utils.cpp @@ -12,169 +12,6 @@ namespace madrona::py { -#ifdef MADRONA_CUDA_SUPPORT -CudaSync::CudaSync(cudaExternalSemaphore_t sema) - : sema_(sema) -{} - -void CudaSync::wait(uint64_t strm) -{ - // Get the current CUDA stream from pytorch and force it to wait - // on an external semaphore to finish - cudaStream_t cuda_strm = (cudaStream_t)strm; - cudaExternalSemaphoreWaitParams params {}; - REQ_CUDA(cudaWaitExternalSemaphoresAsync(&sema_, ¶ms, 1, cuda_strm)); -} - -#ifdef MADRONA_LINUX -void CudaSync::key_() {} -#endif -#endif - -struct TrainInterface::Impl { - HeapArray nameBuffer; - HeapArray namedTensors; - - TrainStepInputInterface inputs; - TrainStepOutputInterface outputs; - Optional checkpointing; - - static inline Impl * init( - TrainStepInputInterface inputs, - TrainStepOutputInterface outputs, - Optional checkpointing); -}; - -TrainInterface::Impl * TrainInterface::Impl::init( - TrainStepInputInterface inputs, - TrainStepOutputInterface outputs, - Optional checkpointing) -{ - CountT num_total_name_chars = 0; - CountT num_total_named_tensors = 0; - - auto sumStorageRequirements = [ - &num_total_name_chars, &num_total_named_tensors - ](Span tensors) - { - for (NamedTensor named_tensor : tensors) { - num_total_name_chars += strlen(named_tensor.name) + 1; - } - - num_total_named_tensors += tensors.size(); - }; - - sumStorageRequirements(inputs.actions); - sumStorageRequirements(inputs.pbt); - - sumStorageRequirements(outputs.observations); - sumStorageRequirements(outputs.stats); - sumStorageRequirements(outputs.pbt); - - HeapArray name_buffer(num_total_name_chars); - HeapArray named_tensors(num_total_named_tensors); - - char *cur_name_ptr = name_buffer.data(); - NamedTensor *cur_named_tensor_ptr = named_tensors.data(); - - auto makeOwnedName = [ - &cur_name_ptr - ](const char *in_name) - { - char *out_name = cur_name_ptr; - - size_t name_len = strlen(in_name) + 1; - memcpy(out_name, in_name, name_len); - cur_name_ptr += name_len; - - return out_name; - }; - - auto makeOwnedNamedTensors = [ - &makeOwnedName, &cur_named_tensor_ptr - ](Span inputs) - { - NamedTensor *out_start = cur_named_tensor_ptr; - - for (const NamedTensor &named_in : inputs) { - const char *owned_name = makeOwnedName(named_in.name); - *(cur_named_tensor_ptr++) = NamedTensor { - .name = owned_name, - .tensor = named_in.tensor, - }; - } - - return Span(out_start, inputs.size()); - }; - - TrainStepInputInterface owned_inputs { - .actions = makeOwnedNamedTensors(inputs.actions), - .resets = inputs.resets, - .simCtrl = inputs.simCtrl, - .pbt = makeOwnedNamedTensors(inputs.pbt), - }; - - TrainStepOutputInterface owned_outputs { - .observations = makeOwnedNamedTensors(outputs.observations), - .rewards = outputs.rewards, - .dones = outputs.dones, - .stats = makeOwnedNamedTensors(outputs.stats), - .pbt = makeOwnedNamedTensors(outputs.pbt), - }; - - Optional owned_checkpointing = - Optional::none(); - - if (checkpointing.has_value()) { - owned_checkpointing = TrainCheckpointingInterface { - .checkpointData = checkpointing->checkpointData, - }; - } - - assert(cur_name_ptr == name_buffer.data() + name_buffer.size()); - assert(cur_named_tensor_ptr == - named_tensors.data() + named_tensors.size()); - - return new Impl { - .nameBuffer = std::move(name_buffer), - .namedTensors = std::move(named_tensors), - .inputs = owned_inputs, - .outputs = owned_outputs, - .checkpointing = std::move(owned_checkpointing), - }; -} - -TrainInterface::TrainInterface() - : impl_(nullptr) -{} - -TrainInterface::TrainInterface( - TrainStepInputInterface step_inputs, - TrainStepOutputInterface step_outputs, - Optional checkpointing) - : impl_(Impl::init(step_inputs, step_outputs, std::move(checkpointing))) -{} - -TrainInterface::TrainInterface(TrainInterface &&) = default; -TrainInterface::~TrainInterface() = default; - -TrainInterface & TrainInterface::operator=(TrainInterface &&o) = default; - -TrainStepInputInterface TrainInterface::stepInputs() const -{ - return impl_->inputs; -} - -TrainStepOutputInterface TrainInterface::stepOutputs() const -{ - return impl_->outputs; -} - -Optional TrainInterface::checkpointing() const -{ - return impl_->checkpointing; -} - Tensor::Printer::Printer(Printer &&o) : dev_ptr_(o.dev_ptr_), print_ptr_(o.print_ptr_) @@ -397,176 +234,7 @@ TensorInterface Tensor::interface() const } #ifdef MADRONA_LINUX -void PyExecMode::key_() {} void Tensor::key_() {} -void TrainInterface::key_() {} -#endif - -[[maybe_unused]] static inline uint64_t numTensorBytes(const Tensor &t) -{ - uint64_t num_items = 1; - uint64_t num_dims = t.numDims(); - for (uint64_t i = 0; i < num_dims; i++) { - num_items *= t.dims()[i]; - } - - return num_items * (uint64_t)t.numBytesPerItem(); -} - -void TrainInterface::cpuCopyStepInputs(void **buffers) -{ - auto copyToSim = [](const Tensor &dst, void *src) { - uint64_t num_bytes = numTensorBytes(dst); - - if (dst.isOnGPU()) { -#ifdef MADRONA_CUDA_SUPPORT - REQ_CUDA(cudaMemcpy(dst.devicePtr(), src, num_bytes, cudaMemcpyHostToDevice)); -#else - assert(false); -#endif - } else { - memcpy(dst.devicePtr(), src, num_bytes); - } - }; - - TrainStepInputInterface &inputs = impl_->inputs; - - for (const NamedTensor &t : inputs.actions) { - copyToSim(t.tensor, *buffers++); - } - - copyToSim(inputs.resets, *buffers++); - copyToSim(inputs.simCtrl, *buffers++); - - for (const NamedTensor &t : inputs.pbt) { - copyToSim(t.tensor, *buffers++); - } -} - -void TrainInterface::cpuCopyObservations(void **buffers) -{ - auto copyFromSim = [](void *dst, const Tensor &src) { - uint64_t num_bytes = numTensorBytes(src); - - if (src.isOnGPU()) { -#ifdef MADRONA_CUDA_SUPPORT - REQ_CUDA(cudaMemcpy(dst, src.devicePtr(), num_bytes, cudaMemcpyHostToDevice)); -#else - assert(false); -#endif - } else { - memcpy(dst, src.devicePtr(), num_bytes); - } - }; - - for (const NamedTensor &t : impl_->outputs.observations) { - copyFromSim(*buffers++, t.tensor); - } -} - -void TrainInterface::cpuCopyStepOutputs(void **buffers) -{ - auto copyFromSim = [](void *dst, const Tensor &src) { - uint64_t num_bytes = numTensorBytes(src); - - if (src.isOnGPU()) { -#ifdef MADRONA_CUDA_SUPPORT - REQ_CUDA(cudaMemcpy(dst, src.devicePtr(), num_bytes, cudaMemcpyHostToDevice)); -#else - assert(false); -#endif - } else { - memcpy(dst, src.devicePtr(), num_bytes); - } - }; - - TrainStepOutputInterface &outputs = impl_->outputs; - - for (const NamedTensor &t : outputs.observations) { - copyFromSim(*buffers++, t.tensor); - } - - copyFromSim(*buffers++, outputs.rewards); - copyFromSim(*buffers++, outputs.dones); - - for (const NamedTensor &t : outputs.stats) { - copyFromSim(*buffers++, t.tensor); - } - - for (const NamedTensor &t : outputs.pbt) { - copyFromSim(*buffers++, t.tensor); - } -} - - -#ifdef MADRONA_CUDA_SUPPORT -void ** TrainInterface::cudaCopyStepInputs(cudaStream_t strm, void **buffers) -{ - auto copyToSim = [&strm](const Tensor &dst, void *src) { - uint64_t num_bytes = numTensorBytes(dst); - - REQ_CUDA(cudaMemcpyAsync(dst.devicePtr(), src, num_bytes, - dst.isOnGPU() ? cudaMemcpyDeviceToDevice : cudaMemcpyDeviceToHost, - strm)); - }; - - TrainStepInputInterface &inputs = impl_->inputs; - - for (const NamedTensor &t : inputs.actions) { - copyToSim(t.tensor, *buffers++); - } - - copyToSim(inputs.resets, *buffers++); - copyToSim(inputs.simCtrl, *buffers++); - - for (const NamedTensor &t : inputs.pbt) { - copyToSim(t.tensor, *buffers++); - } - - return buffers; -} - -void TrainInterface::cudaCopyObservations(cudaStream_t strm, void **buffers) -{ - auto copyFromSim = [&strm](void *dst, const Tensor &src) { - uint64_t num_bytes = numTensorBytes(src); - - REQ_CUDA(cudaMemcpyAsync(dst, src.devicePtr(), num_bytes, - src.isOnGPU() ? cudaMemcpyDeviceToDevice : cudaMemcpyHostToDevice, strm)); - }; - - for (const NamedTensor &t : impl_->outputs.observations) { - copyFromSim(*buffers++, t.tensor); - } -} - -void TrainInterface::cudaCopyStepOutputs(cudaStream_t strm, void **buffers) -{ - auto copyFromSim = [&strm](void *dst, const Tensor &src) { - uint64_t num_bytes = numTensorBytes(src); - - REQ_CUDA(cudaMemcpyAsync(dst, src.devicePtr(), num_bytes, - src.isOnGPU() ? cudaMemcpyDeviceToDevice : cudaMemcpyHostToDevice, strm)); - }; - - TrainStepOutputInterface &outputs = impl_->outputs; - - for (const NamedTensor &t : outputs.observations) { - copyFromSim(*buffers++, t.tensor); - } - - copyFromSim(*buffers++, outputs.rewards); - copyFromSim(*buffers++, outputs.dones); - - for (const NamedTensor &t : outputs.stats) { - copyFromSim(*buffers++, t.tensor); - } - - for (const NamedTensor &t : outputs.pbt) { - copyFromSim(*buffers++, t.tensor); - } -} - #endif } From 75a00e29ca7c2ca4e1c82ddf47642439030c86d9 Mon Sep 17 00:00:00 2001 From: Alexis Duburcq Date: Thu, 23 Jul 2026 13:27:31 +0200 Subject: [PATCH 10/14] Delete dead shaders, font asset, dev scripts and an orphan header None of these are referenced by the headless batch path: the viewer/ deferred-depth/voxel/blur/grid/shadow_gen/textured_quad shaders and the unused vk basic.hlsl are never compiled at runtime; font.ttf has no loader; scripts/ (profiling + benchmarks) is not part of the build; and block_list.hpp has no includers. --- include/madrona/block_list.hpp | 92 ---- scripts/parse_device_tracing.py | 466 ---------------- scripts/parse_host_tracing.py | 97 ---- scripts/perf_benchmark/README.md | 442 ---------------- scripts/perf_benchmark/batch_benchmark.py | 385 -------------- .../benchmark_assets/plane_urdf/checker.png | Bin 1902 -> 0 bytes .../benchmark_assets/plane_urdf/plane.mtl | 14 - .../benchmark_assets/plane_urdf/plane.urdf | 26 - .../benchmark_assets/plane_urdf/plane100.obj | 22 - .../benchmark_assets/plane_usd/.asset_hash | 1 - .../benchmark_assets/plane_usd/config.yaml | 18 - .../materials/textures/checker.png | Bin 1902 -> 0 bytes .../plane_usd/configuration/plane_base.usd | Bin 4313 -> 0 bytes .../plane_usd/configuration/plane_physics.usd | Bin 1784 -> 0 bytes .../plane_usd/configuration/plane_sensor.usd | Bin 645 -> 0 bytes .../benchmark_assets/plane_usd/plane.usd | Bin 1532 -> 0 bytes scripts/perf_benchmark/benchmark_configs.py | 45 -- scripts/perf_benchmark/benchmark_madrona.py | 141 ----- scripts/perf_benchmark/benchmark_maniskill.py | 209 -------- scripts/perf_benchmark/benchmark_omni.py | 330 ------------ scripts/perf_benchmark/benchmark_profiler.py | 270 ---------- scripts/perf_benchmark/benchmark_pyrender.py | 136 ----- .../benchmark_report_generator.py | 489 ----------------- .../configs/benchmark_config_full.yml | 100 ---- .../configs/benchmark_config_madrona.yml | 68 --- .../configs/benchmark_config_maniskill.yml | 58 -- .../configs/benchmark_config_omni.yml | 61 --- .../configs/benchmark_config_smoke_test.yml | 49 -- .../perf_benchmark/example_report/index.html | 123 ----- ...rona raytracer_128x128_comparison_plot.png | Bin 74700 -> 0 bytes ...ona raytracer_128x128_comparison_table.png | Bin 47610 -> 0 bytes .../panda_madrona_rasterizer_plot.png | Bin 72824 -> 0 bytes .../panda_madrona_raytracer_plot.png | Bin 69796 -> 0 bytes .../example_report/perf_data.csv | 71 --- scripts/perf_benchmark/process_xml.py | 104 ---- scripts/profile.py | 106 ---- src/render/font.ttf | Bin 340712 -> 0 bytes src/render/shaders/batch_draw_depth.hlsl | 102 ---- src/render/shaders/blur.hlsl | 58 -- src/render/shaders/draw_deferred_depth.hlsl | 160 ------ src/render/shaders/grid_draw.hlsl | 122 ----- src/render/shaders/shadow_gen.hlsl | 164 ------ src/render/shaders/textured_quad.hlsl | 64 --- .../shaders/viewer_deferred_lighting.hlsl | 334 ------------ src/render/shaders/viewer_shadow_draw.hlsl | 76 --- src/render/shaders/voxel_draw.hlsl | 114 ---- src/render/shaders/voxel_gen.hlsl | 500 ------------------ 47 files changed, 5617 deletions(-) delete mode 100644 include/madrona/block_list.hpp delete mode 100644 scripts/parse_device_tracing.py delete mode 100644 scripts/parse_host_tracing.py delete mode 100644 scripts/perf_benchmark/README.md delete mode 100644 scripts/perf_benchmark/batch_benchmark.py delete mode 100644 scripts/perf_benchmark/benchmark_assets/plane_urdf/checker.png delete mode 100644 scripts/perf_benchmark/benchmark_assets/plane_urdf/plane.mtl delete mode 100644 scripts/perf_benchmark/benchmark_assets/plane_urdf/plane.urdf delete mode 100644 scripts/perf_benchmark/benchmark_assets/plane_urdf/plane100.obj delete mode 100644 scripts/perf_benchmark/benchmark_assets/plane_usd/.asset_hash delete mode 100644 scripts/perf_benchmark/benchmark_assets/plane_usd/config.yaml delete mode 100644 scripts/perf_benchmark/benchmark_assets/plane_usd/configuration/materials/textures/checker.png delete mode 100644 scripts/perf_benchmark/benchmark_assets/plane_usd/configuration/plane_base.usd delete mode 100644 scripts/perf_benchmark/benchmark_assets/plane_usd/configuration/plane_physics.usd delete mode 100644 scripts/perf_benchmark/benchmark_assets/plane_usd/configuration/plane_sensor.usd delete mode 100644 scripts/perf_benchmark/benchmark_assets/plane_usd/plane.usd delete mode 100644 scripts/perf_benchmark/benchmark_configs.py delete mode 100644 scripts/perf_benchmark/benchmark_madrona.py delete mode 100644 scripts/perf_benchmark/benchmark_maniskill.py delete mode 100644 scripts/perf_benchmark/benchmark_omni.py delete mode 100644 scripts/perf_benchmark/benchmark_profiler.py delete mode 100644 scripts/perf_benchmark/benchmark_pyrender.py delete mode 100644 scripts/perf_benchmark/benchmark_report_generator.py delete mode 100644 scripts/perf_benchmark/configs/benchmark_config_full.yml delete mode 100644 scripts/perf_benchmark/configs/benchmark_config_madrona.yml delete mode 100644 scripts/perf_benchmark/configs/benchmark_config_maniskill.yml delete mode 100644 scripts/perf_benchmark/configs/benchmark_config_omni.yml delete mode 100644 scripts/perf_benchmark/configs/benchmark_config_smoke_test.yml delete mode 100644 scripts/perf_benchmark/example_report/index.html delete mode 100644 scripts/perf_benchmark/example_report/panda_madrona rasterizer_ madrona raytracer_128x128_comparison_plot.png delete mode 100644 scripts/perf_benchmark/example_report/panda_madrona rasterizer_ madrona raytracer_128x128_comparison_table.png delete mode 100644 scripts/perf_benchmark/example_report/panda_madrona_rasterizer_plot.png delete mode 100644 scripts/perf_benchmark/example_report/panda_madrona_raytracer_plot.png delete mode 100644 scripts/perf_benchmark/example_report/perf_data.csv delete mode 100644 scripts/perf_benchmark/process_xml.py delete mode 100644 scripts/profile.py delete mode 100644 src/render/font.ttf delete mode 100644 src/render/shaders/batch_draw_depth.hlsl delete mode 100644 src/render/shaders/blur.hlsl delete mode 100644 src/render/shaders/draw_deferred_depth.hlsl delete mode 100644 src/render/shaders/grid_draw.hlsl delete mode 100644 src/render/shaders/shadow_gen.hlsl delete mode 100644 src/render/shaders/textured_quad.hlsl delete mode 100644 src/render/shaders/viewer_deferred_lighting.hlsl delete mode 100644 src/render/shaders/viewer_shadow_draw.hlsl delete mode 100644 src/render/shaders/voxel_draw.hlsl delete mode 100644 src/render/shaders/voxel_gen.hlsl diff --git a/include/madrona/block_list.hpp b/include/madrona/block_list.hpp deleted file mode 100644 index 307f5592..00000000 --- a/include/madrona/block_list.hpp +++ /dev/null @@ -1,92 +0,0 @@ -#pragma once - -#include -#include - -namespace madrona { - -template -class BlockList { - struct BlockBase {}; - - struct Metadata { - BlockBase *next; - CountT numElems; - }; - - template - struct Block : BlockBase { - T arr[num_elems]; - Metadata metadata; - }; - - static constexpr inline CountT computeElemsPerBlock() - { - static_assert(desired_elems_per_block >= 0); - - if constexpr (desired_elems_per_block != 0) { - return desired_elems_per_block; - } - - constexpr CountT default_block_size = 1024; - - sizeof(T) - - constexpr CountT num_elems = (default_block_size - sizeof(Metadata)) / sizeof(T); - - using TestT = Block - - if constexpr (num_elems == 0) { - return 1; - } - - return num_elems; - } - - static constexpr inline CountT per_block_ = computeElemsPerBlock(); - - struct Block { - T arr[per_block_]; - Metadata metadata; - }; - - -public: - BlockList() - : head_(nullptr) - {} - - class Iter { - public: - - private: - Block *cur_block_; - CountT cur_offset_; - - friend class BlockList; - }; - - Iter begin() - { - } - - Iter end() - { - return Iter { - }; - } - -private: - static inline constexpr CountT num_elems_ = - block_size / - - union Block { - Block *next; - CountT numElems; - }; - - Block *head_; -}; - -} diff --git a/scripts/parse_device_tracing.py b/scripts/parse_device_tracing.py deleted file mode 100644 index 74148171..00000000 --- a/scripts/parse_device_tracing.py +++ /dev/null @@ -1,466 +0,0 @@ -import os -import sys -import argparse -import pandas as pd -from PIL import Image, ImageDraw, ImageFont - -HIDE_SEEK = False -SPLIT_LINES = False -MAX_BLOCKS_PER_SM = 6 - - -def parse_device_logs(events): - LOG_STEPS = {} - STEP = -1 - - def new_step(num_warps, num_blocks, num_sms): - nonlocal STEP - STEP += 1 - LOG_STEPS[STEP] = { - "events": {}, - "SMs": {}, - "mapping": {}, - # "final_cycles": {}, - "start_timestamp": 0xFFFFFFFFFFFFFFFF, - "final_timestamp": 0, - "num_warps": num_warps, - "num_blocks": num_blocks, - "num_sms": num_sms, - "configs": {}, - } - - for i in range(0, len(events), 40): - array = events[i : i + 40] - - event = int.from_bytes(array[:4], byteorder="little") - funcID = int.from_bytes(array[4:8], byteorder="little") - numInvocations = int.from_bytes(array[8:12], byteorder="little") - nodeID = int.from_bytes(array[12:16], byteorder="little") - warpID = int.from_bytes(array[16:20], byteorder="little") - blockID = int.from_bytes(array[20:24], byteorder="little") - smID = int.from_bytes(array[24:28], byteorder="little") - logIndex = int.from_bytes(array[28:32], byteorder="little") - cycleCount = int.from_bytes(array[32:40], byteorder="little") - - if STEP != -1: - # to make a unique warp id - warpID += blockID * LOG_STEPS[STEP]["num_warps"] - - # print("event: {}, funcID: {}, numInvocations: {}, nodeID: {}, warpID: {}, blockID: {}, smID: {}, cycleCount: {}, logIndex: {}".format(event, funcID, numInvocations, nodeID, warpID, blockID, smID, cycleCount, logIndex)) - if event == 0: - # for calibration event, we log kernel config instead of node - # when logIndex == 0, it is the first node of the whole step - if logIndex == 0: - new_step(num_warps=funcID, num_blocks=numInvocations, num_sms=nodeID) - LOG_STEPS[STEP]["start_timestamp"] = cycleCount - else: - assert LOG_STEPS[STEP]["num_warps"] == funcID and LOG_STEPS[STEP]["num_sms"] == nodeID - LOG_STEPS[STEP]["num_blocks"] = numInvocations - - elif event in [1, 2]: - if nodeID not in LOG_STEPS[STEP]["mapping"]: - LOG_STEPS[STEP]["mapping"][nodeID] = (funcID, numInvocations) - else: - assert LOG_STEPS[STEP]["mapping"][nodeID] == (funcID, numInvocations) - - if nodeID not in LOG_STEPS[STEP]["events"]: - LOG_STEPS[STEP]["events"][nodeID] = {event: (smID, warpID, cycleCount)} - else: - assert event not in LOG_STEPS[STEP]["events"][nodeID] - LOG_STEPS[STEP]["events"][nodeID][event] = (smID, warpID, cycleCount) - if nodeID not in LOG_STEPS[STEP]["configs"]: - LOG_STEPS[STEP]["configs"][nodeID] = {"num_blocks": LOG_STEPS[STEP]["num_blocks"]} - - elif event in [3, 4]: - if smID not in LOG_STEPS[STEP]["SMs"]: - assert event == 3 - LOG_STEPS[STEP]["SMs"][smID] = {(numInvocations, nodeID, warpID): [cycleCount]} - else: - if (numInvocations, nodeID, warpID) in LOG_STEPS[STEP]["SMs"][smID]: - assert event == 4 - LOG_STEPS[STEP]["SMs"][smID][(numInvocations, nodeID, warpID)].append(cycleCount) - else: - assert event == 3 - LOG_STEPS[STEP]["SMs"][smID][(numInvocations, nodeID, warpID)] = [cycleCount] - - elif event == 5: - # assert warpID not in LOG_STEPS[STEP]["final_cycles"] - # LOG_STEPS[STEP]["final_cycles"][warpID] = cycleCount - LOG_STEPS[STEP]["final_timestamp"] = max(LOG_STEPS[STEP]["final_timestamp"], cycleCount) - else: - assert False & "event {} not supported".format(event) - - # drop the last step which might be corrupted - # del LOG_STEPS[STEP] - print("At the end, complete traces for {} steps are generated".format(STEP)) - - for s in LOG_STEPS: - LOG_STEPS[s]["final_timestamp"] -= LOG_STEPS[s]["start_timestamp"] - # for b in LOG_STEPS[s]["final_cycles"]: - # LOG_STEPS[s]["final_cycles"][b] -= LOG_STEPS[s]["start_timestamp"] - - return LOG_STEPS - - -def serialized_analysis(step_log, nodes_map): - - def calibrate(timestamp, reference): - return timestamp[2] - reference - - for i in range(max(step_log["events"]) + 1): - # skipped nodes - if i not in step_log["events"]: - continue - nodes_map[i] = { - "nodeID": i, - "funcID": step_log["mapping"][i][0], - "invocations": step_log["mapping"][i][1], - "start": calibrate(step_log["events"][i][1], step_log["start_timestamp"]), - "end": calibrate(step_log["events"][i][2], step_log["start_timestamp"]), - "SM utilization": [], - } - nodes_map[i]["duration (ns)"] = nodes_map[i]["end"] - nodes_map[i]["start"] - - total_exec_time = sum(v["duration (ns)"] for v in nodes_map.values()) - for i in nodes_map: - nodes_map[i]["percentage (%)"] = nodes_map[i]["duration (ns)"] / total_exec_time * 100 - - -def block_analysis(step_log, nodes_map): - sm_execution = {k: [] for k in step_log["SMs"].keys()} - block_exec_time = { - "blocks": {k: {} for k in step_log["SMs"].keys()}, # horizontal - "nodes": {k: {} for k in step_log["SMs"].keys()}, # vertical - } - - for sm in step_log["SMs"]: - for (_, nodeID, warpID), time_stamps in step_log["SMs"][sm].items(): - assert len(time_stamps) == 2 - start = time_stamps[0] - end = max(time_stamps[1:]) - # confirm clock does proceed within an SM - assert end >= start - assert start > step_log["start_timestamp"] - - start, end = [i - step_log["start_timestamp"] for i in [start, end]] - sm_execution[sm].append((start, end)) - - if warpID not in block_exec_time["blocks"][sm]: - block_exec_time["blocks"][sm][warpID] = [(start, end, nodeID)] - else: - assert start > block_exec_time["blocks"][sm][warpID][-1][1] - block_exec_time["blocks"][sm][warpID].append((start, end, nodeID)) - - if nodeID not in block_exec_time["nodes"][sm]: - block_exec_time["nodes"][sm][nodeID] = [] - block_exec_time["nodes"][sm][nodeID].append((start, end, warpID)) - - block_exec_time["blocks"][sm] = {k: sorted(v) for k, v in block_exec_time["blocks"][sm].items()} - block_exec_time["nodes"][sm] = {k: sorted(v) for k, v in block_exec_time["nodes"][sm].items()} - - for s in sm_execution: - intervals = [] - for start, end in sm_execution[s]: - if len(intervals) == 0: - intervals.append((start, end)) - continue - p = 0 - while p < len(intervals): - if start > intervals[p][1]: - p += 1 - continue - elif end < intervals[p][0]: - intervals.insert(p, (start, end)) - break - else: - intervals[p] = ( - min(start, intervals[p][0]), - max(end, intervals[p][1]), - ) - break - else: - intervals.insert(p, (start, end)) - - i_pointer = 0 - for k, v in nodes_map.items(): - occupied_time = 0 - while i_pointer < len(intervals): - i_start, i_end = intervals[i_pointer] - if v["end"] < i_start: - break - elif v["start"] <= i_start and v["end"] >= i_end: - occupied_time += i_end - i_start - i_pointer += 1 - continue - else: - assert False and "no intersections" - nodes_map[k]["SM utilization"].append(occupied_time / nodes_map[k]["duration (ns)"]) - - for i in nodes_map: - assert len(nodes_map[i]["SM utilization"]) == len(sm_execution) - nodes_map[i]["SM utilization"] = sum(nodes_map[i]["SM utilization"]) / len(sm_execution) - - print( - "For each SM on average, {:.3f}% of the time there is at least one block is running on".format( - sum(v["SM utilization"] * v["percentage (%)"] for v in nodes_map.values()) - ) - ) - - return block_exec_time - - -COLORS = [ - "blue", - "orange", - "red", - "green", - "purple", - "cyan", - "pink", - "magenta", - "olive", - "navy", - "teal", - "maroon", - "yellow", - "black", -] - - -def plot_events(step_log, nodes_map, blocks, file_name, args): - # todo: here we have an assumption that each SM has the same number of blocks, which are expected to be true - num_sms = step_log["num_sms"] - # num_block_per_sm = step_log["num_blocks"] - num_warp_per_sm = step_log["num_warps"] * MAX_BLOCKS_PER_SM - # num_block_per_sm = 8 - num_pixel_per_warp = 1 - sm_interval_pixel = num_pixel_per_warp * 3 - num_pixel_per_sm = num_warp_per_sm * num_pixel_per_warp + sm_interval_pixel - y_blank = num_pixel_per_warp * num_warp_per_sm * (num_sms // 8) - y_limit = num_sms * num_pixel_per_sm + y_blank - x_limit = y_limit * args.aspect_ratio - print("the figure size will be {}x{}".format(x_limit, y_limit)) - - top_nodes = sorted( - [ - i[0] - for i in sorted(nodes_map.items(), key=lambda item: item[1]["duration (ns)"])[ - len(nodes_map) - args.num_highlight_nodes : - ] - ] - ) - - colors = {} - if HIDE_SEEK: - colors = { - # narrowphase - 28: (0, 76, 153), - # solvers - 20: (0, 102, 204), - 22: (0, 128, 255), - 24: (102, 178, 255), - 26: (178, 216, 255), - # broadphase - 30: (199, 31, 102), - 34: (230, 96, 152), - 36: (248, 181, 209), - # Collect Observations System - 52: (139, 235, 219), - # Compute Visibility System - 54: (90, 184, 168), - # LIDAR System - 56: (148, 112, 206), - } - else: - for n in top_nodes: - func = nodes_map[n]["funcID"] - if func not in colors and len(colors) < len(COLORS): - colors[func] = COLORS[len(colors)] - print("Color mapping for functions:", colors) - - img = Image.new("RGB", (x_limit, y_limit), "white") - draw = ImageDraw.Draw(img) - - def cast_coor(timestamp, limit=x_limit): - assert timestamp <= step_log["final_timestamp"] - if args.fixed_scale: - return int(timestamp / args.tpp) - else: - return int(timestamp / step_log["final_timestamp"] * limit) - - color_span = {} - for n in nodes_map: - if nodes_map[n]["funcID"] not in colors: - continue - left, right = cast_coor(nodes_map[n]["start"]), cast_coor(nodes_map[n]["end"]) - color_span[(left, right)] = colors[nodes_map[n]["funcID"]] - sorted_color_span = sorted(color_span.keys()) - - num_stamps = active_warps = 0 - - for sm, b in blocks.items(): - node_pixels_sm = {} - y = (sm + 1) * num_pixel_per_sm - vertical_pixels = {} - for warp, events in b.items(): - # to avoid duplication - last_end_pixel = -1 - for e in events: - start = max(last_end_pixel + 1, cast_coor(e[0])) - end = cast_coor(e[1]) - if e[2] not in node_pixels_sm: - node_pixels_sm[e[2]] = [start, end] - else: - node_pixels_sm[e[2]] = [ - min(node_pixels_sm[e[2]][0], start), - max(node_pixels_sm[e[2]][1], end), - ] - for i in range(start, end + 1): - if i not in vertical_pixels: - vertical_pixels[i] = 1 * MAX_BLOCKS_PER_SM / step_log["configs"][e[2]]["num_blocks"] - else: - vertical_pixels[i] += 1 * MAX_BLOCKS_PER_SM / step_log["configs"][e[2]]["num_blocks"] - last_end_pixel = end - - n_pointer = 0 - for node_start, node_end in sorted(node_pixels_sm.values()): - for p in range(node_start, node_end + 1): - if p not in vertical_pixels: - vertical_pixels[p] = 0 - num_stamps += num_warp_per_sm - active_warps += vertical_pixels[p] - - while n_pointer < len(color_span): - left, right = sorted_color_span[n_pointer] - if p > right: - n_pointer += 1 - continue - elif p < left: - bar_color = (0, 0, 0) - break - else: - bar_color = color_span[(left, right)] - break - else: - bar_color = (0, 0, 0) - assert vertical_pixels[p] <= num_warp_per_sm - # if vertical_pixels[p] != 0: - draw.line( - (p, y, p, y - int(vertical_pixels[p] * num_pixel_per_warp)), - fill=bar_color, - width=1 if vertical_pixels[p] != 0 else 0, - ) - y_low = y - int(vertical_pixels[p] * num_pixel_per_warp) - y_low -= 1 if vertical_pixels[p] != 0 else 0 - y_high = y - num_warp_per_sm * num_pixel_per_warp - if y_low <= y_high: - pass - else: - draw.line((p, y_low, p, y_high), fill=(211, 211, 211), width=1) - - if SPLIT_LINES: - # indicate the start of each splitted kernel - nodes = sorted(nodes_map.keys()) - last_node = nodes[0] - for n in nodes[1:]: - if step_log["configs"][n]["num_blocks"] != step_log["configs"][last_node]["num_blocks"]: - node_start = cast_coor(nodes_map[n]["start"]) - draw.line( - (node_start, 0, node_start, y_limit - y_blank / 2), - fill="plum", - width=2, - ) - last_node = n - - print("Percentage of active warps is {:.2f}%".format(active_warps / num_stamps * 100)) - - fontsize = 65 - font = ImageFont.load_default(size=fontsize) - - if not HIDE_SEEK: - # mark the start and the end of major nodes_map - y_shift = 0.9 - for n in top_nodes: - # for n, v in nodes_map.items(): - left, right = cast_coor(nodes_map[n]["start"]), cast_coor(nodes_map[n]["end"]) - draw.line((left, 0, left, y_limit), fill="red", width=1) - draw.line((right, 0, right, y_limit), fill="green", width=1) - draw.text( - (left, y_limit - y_blank * y_shift), - " f: {}\n t: {:.3f}ms\n {:.1f}%".format( - nodes_map[n]["funcID"], - (nodes_map[n]["duration (ns)"]) / 1000000, - nodes_map[n]["percentage (%)"], - ), - fill=(0, 0, 0), - font=font, - ) - y_shift = 1.3 - y_shift - - img.save(file_name) - - -def step_analysis(step_log, file_name, tabular_data, args=None): - nodes_map = {} - serialized_analysis(step_log, nodes_map) - - block_exec_time = block_analysis(step_log, nodes_map) - if args is not None: - plot_events(step_log, nodes_map, block_exec_time["blocks"], file_name, args) - - for n in nodes_map: - tabular_data = pd.concat([tabular_data, pd.DataFrame({k: [v] for k, v in nodes_map[n].items()})]) - return tabular_data - - -if __name__ == "__main__": - parser = argparse.ArgumentParser() - parser.add_argument("--trace_file", type=str, required=True) - parser.add_argument("--start_step", type=int, default=10, help="analysis start from which step") - parser.add_argument("--num_steps", type=int, default=5, help="number of steps to be analyzed") - parser.add_argument("--num_highlight_nodes", type=int, default=16) - parser.add_argument("--aspect_ratio", type=float, default=2) - parser.add_argument("--fixed_scale", action="store_true") - parser.add_argument("--tpp", type=int, default=8000, help="time(ns) per pixel") - - args = parser.parse_args() - - with open(args.trace_file, "rb") as f: - events = bytearray(f.read()) - assert len(events) % 40 == 0 - print("{} events were logged in total".format(len(events) // 40)) - LOG_STEPS = parse_device_logs(events) - - steps = args.num_steps - start_from = args.start_step - - dir_path = args.trace_file + "_megakernel_events" - isExist = os.path.exists(dir_path) - if not isExist: - os.mkdir(dir_path) - # todo: limit - assert start_from < len(LOG_STEPS) - end_at = min(start_from + steps, len(LOG_STEPS)) - - tabular_data = [ - pd.DataFrame( - { - "nodeID": [], - "funcID": [], - "duration (ns)": [], - "invocations": [], - "percentage (%)": [], - "SM utilization": [], - } - ) - for _ in range(start_from, end_at) - ] - - with pd.ExcelWriter(dir_path + "/metrics.xlsx") as writer: - for s in range(start_from, end_at): - step_analysis( - LOG_STEPS[s], - dir_path + "/step{}.png".format(s), - tabular_data[s - start_from], - args, - ).to_excel(writer, sheet_name="step{}".format(s), index=False) diff --git a/scripts/parse_host_tracing.py b/scripts/parse_host_tracing.py deleted file mode 100644 index 9f3b9b6a..00000000 --- a/scripts/parse_host_tracing.py +++ /dev/null @@ -1,97 +0,0 @@ -import sys -import numpy as np -import matplotlib.pyplot as plt -from PIL import Image, ImageDraw - - -def read_binary_file(file_name): - with open(file_name, "rb") as f: - # Read the contents of the file into a NumPy array - events, time_stamps = np.fromfile(f, dtype=np.int64).reshape(2, -1) - # set the time stamp of first event to be 0 - time_stamps = [i - time_stamps[0] for i in time_stamps] - return events, time_stamps - - -# apt to change -event_dict = {0: 1, 2: 3, 4: 5} -color_dict = {1: "r", 3: "g", 5: "b"} - - -def get_steps(events, time_stamps): - steps = [] - for i in range(len(events)): - if events[i : i + 4].tolist() == [2, 3, 4, 5]: - steps.append(time_stamps[i : i + 4]) - return steps - - -def plot_events(events, time_stamps, file_name, exclude_init=True, drop_warmup=2, display_steps=20): - - steps = get_steps(events, time_stamps) - steps = steps[drop_warmup : drop_warmup + display_steps] - steps = [[i - s[0] for i in s] for s in steps] - - max_time = max(max(s) for s in steps) - - num_steps = len(steps) - x_limit = 200 - pixel_per_step = 10 - bar_interval = 2 - y_limit = num_steps * (pixel_per_step + bar_interval) - - img = Image.new("RGB", (x_limit, y_limit), "white") - draw = ImageDraw.Draw(img) - - def cast_coor(timestamp, limit=x_limit): - assert timestamp <= max_time - return int(timestamp / max_time * limit) - - for i, s in enumerate(steps): - s = [i - s[0] for i in s] - y = pixel_per_step / 2 + i * (pixel_per_step + bar_interval) - draw.line((0, y, cast_coor(s[1]), y), fill="green", width=pixel_per_step) - draw.line((cast_coor(s[2]), y, cast_coor(s[3]), y), fill="blue", width=pixel_per_step) - # print(s[1]/1000000, (s[3] - s[2])/1000000, s[1] / (s[3] - s[2] + s[1]) ) - - img.save(file_name + "_events.png") - - # num_events = len(events) // 2 - # event_stack = [0] - # event_starts = [] - # event_ends = [] - # event_colors = [] - # for i in range(1, len(events)): - # if events[i] in event_dict: - # event_stack.append(i) - # else: - # assert (events[i] == event_dict[events[event_stack[-1]]]) - # if exclude_init and events[i] == 1: - # num_events -= 1 - # continue - # event_starts.append(time_stamps[event_stack[-1]]) - # event_ends.append(time_stamps[i]) - # event_colors.append(color_dict[events[i]]) - # event_stack.pop() - # assert num_events == len(event_starts) == len(event_ends) - - # num_events -= drop_warmup - # event_starts = event_starts[drop_warmup:] - # event_ends = event_ends[drop_warmup:] - # event_colors = event_colors[drop_warmup:] - - # plt.barh(range(num_events), - # [event_ends[i] - event_starts[i] for i in range(num_events)], - # left=event_starts, - # color=event_colors) - # plt.xlim(min(event_starts), max(event_ends)) - # plt.savefig(file_name + '_events.png') - - -if __name__ == "__main__": - if len(sys.argv) != 2: - print("python parse_tracing.py [file_name]") - exit() - - events, time_stamps = read_binary_file(sys.argv[1]) - plot_events(events, time_stamps, sys.argv[1]) diff --git a/scripts/perf_benchmark/README.md b/scripts/perf_benchmark/README.md deleted file mode 100644 index 6bb7cc6f..00000000 --- a/scripts/perf_benchmark/README.md +++ /dev/null @@ -1,442 +0,0 @@ -# Performance Benchmark Suite - -This directory contains a comprehensive performance benchmarking suite for evaluating rendering and simulation performance across different renderers, configurations, and hardware setups. - -## Overview - -The benchmark suite is designed to systematically test performance across multiple dimensions: -- **Renderers**: Madrona, Omniverse, PyRender, ManiSkill -- **Rendering Modes**: Rasterizer vs Raytracer -- **Batch Sizes**: Multiple environment counts -- **Resolutions**: Various image resolutions -- **Assets**: Different MJCF/URDF models - -## Directory Structure - -``` -perf_benchmark/ -├── README.md # This file -├── batch_benchmark.py # Main batch execution script -├── benchmark_configs.py # Configuration parser -├── benchmark_report_generator.py # Report and visualization generator -├── benchmark_profiler.py # Performance profiling utilities -├── benchmark_madrona.py # Madrona renderer benchmark -├── benchmark_omni.py # Omniverse renderer benchmark -├── benchmark_pyrender.py # PyRender benchmark -├── benchmark_maniskill.py # ManiSkill renderer benchmark -├── process_xml.py # XML asset preprocessing utility -├── configs/ # Configuration files -│ ├── benchmark_config_smoke_test.yml # Quick test configuration -│ ├── benchmark_config_madrona.yml # Madrona-specific config -│ ├── benchmark_config_omni.yml # Omniverse-specific config -│ ├── benchmark_config_maniskill.yml # ManiSkill-specific config -│ └── benchmark_config_full.yml # Comprehensive test config -``` - -## Quick Start - - -### 1. Optional steps -To enable benchmarking with IsaacLab and ManiSkill, follow these optional setup steps: - -- Install IsaacLab - - Download and install IsaacLab from [NVIDIA IsaacLab Documentation](https://isaac-sim.github.io/IsaacLab/main/source/setup/installation/index.html). - - Add the IsaacLab installation directory to your system `PATH`. -- Install ManiSkill - - Install ManiSkill2 by following [ManiSkill Documentation](https://maniskill.readthedocs.io/en/latest/user_guide/). -- Set Environment Variables - - Both IsaacLab and ManiSkill use the `ASSET_DIR` environment variable to locate the Genesis assets directory. - - Use `genesis.utils.misc.asset_dir()` in Genesis to retrieve the exact directory path and then set the environment variable: - ``` - export ASSET_DIR=/path/to/genesis/asset_dir - ``` -- Preprocess Required Assets - - IsaacLab benchmarks require MJCF assets to be preprocessed for compatibility with Omniverse. - ```bash - python process_xml.py --file ./configs/benchmark_config_omni.yml - ``` - -### 2. Run a Quick Smoke Test - -```bash -python batch_benchmark.py -f benchmark_config_smoke_test.yml -``` - -### 3. Run a Full Benchmark Suite - -```bash -python batch_benchmark.py -f benchmark_config_full.yml -``` - -### 4. Continue from a Previous Run - -```bash -python batch_benchmark.py -f benchmark_config_full.yml -c /name/of/previous/run/folder -``` - -## Configuration Files - -Configuration files are YAML-based and define the test parameters. Here's an example structure: - -```yaml -# List of MJCF/URDF files to test -mjcf_list: - - xml/franka_emika_panda/panda.xml - -# Renderer configurations -renderer_list: - - renderer: madrona - benchmark_script: benchmark_madrona.py - timeout: 120 - - renderer: omniverse - benchmark_script: benchmark_omni.py - timeout: 300 - - renderer: maniskill - benchmark_script: benchmark_maniskill.py - timeout: 180 - -# Test rasterizer and raytracer modes -rasterizer_list: - - true # Rasterizer - - false # Raytracer - -# Batch sizes to test -batch_size_list: - - 256 - - 512 - - 1024 - -# Resolutions to test (width x height) -resolution_list: - - [128, 128] - - [256, 256] - - [512, 512] - -# Raytracer settings -raytracer: - max_bounce: 2 - spp: 1 - -# Simulation settings -simulation: - n_steps: 1000 - -# Camera settings -camera: - position: [1.5, 0.5, 1.5] - lookat: [0.0, 0.0, 0.5] - fov: 45.0 - -# Display settings -display: - gui: false - -# Performance comparisons to generate -comparison_list: - - - renderer: madrona - rasterizer: true - - renderer: madrona - rasterizer: false -``` - -## Core Components - -### batch_benchmark.py - -The main orchestration script that: -- Parses configuration files -- Creates test combinations -- Executes benchmarks in parallel -- Handles failures and timeouts -- Generates reports - -**Key Features:** -- Hierarchical test execution (renderer → rasterizer → mjcf → batch_size → resolution) -- Automatic failure handling (skips larger resolutions if smaller ones fail) -- Resume capability from previous runs -- Timeout management per renderer - -### benchmark_configs.py - -Configuration parser that loads and validates YAML configuration files. - -**Supported Configuration Sections:** -- `mjcf_list`: List of asset files to test -- `renderer_list`: Renderer configurations with timeouts -- `rasterizer_list`: Boolean flags for rasterizer/raytracer modes -- `batch_size_list`: Environment counts to test -- `resolution_list`: Image resolutions to test -- `raytracer`: Raytracing parameters (max_bounce, spp) -- `simulation`: Simulation parameters (n_steps) -- `camera`: Camera positioning and settings -- `display`: Display options (gui) -- `comparison_list`: Performance comparison definitions - -### benchmark_profiler.py - -High-precision performance profiling using CUDA events and CPU timing. - -**Profiling Capabilities:** -- GPU timing using CUDA events -- CPU timing using high-resolution timers -- Per-step detailed timing -- Per-environment timing calculations -- FPS calculations (total and per-environment) - -**Key Methods:** -- `on_simulation_start()`: Start simulation timing -- `on_rendering_start()`: Start rendering timing -- `on_rendering_end()`: End rendering timing -- `get_total_rendering_gpu_time()`: Total GPU rendering time -- `get_rendering_fps()`: Overall FPS -- `get_rendering_fps_per_env()`: FPS per environment - -### benchmark_report_generator.py - -Generates comprehensive performance reports and visualizations. - -**Output Types:** -- Individual performance plots per MJCF/rasterizer combination -- Comparison plots between renderers -- HTML report with embedded plots and tables -- Performance summary tables - -**Visualization Features:** -- FPS vs batch size plots -- Resolution-based color coding -- Performance comparison charts -- Interactive HTML reports - -### process_xml.py - -Utility script for preprocessing MJCF/XML assets to ensure compatibility with different renderers. - -**Features:** -- Wraps visual geometry elements in body tags -- Generates unique identifiers for mesh elements -- Handles collision geometry appropriately -- Creates processed XML files with `_new.xml` suffix - -**Usage:** -```bash -python process_xml.py --file ./genesis/assets/xml/franka_emika_panda/panda.xml -``` - -## Renderer-Specific Scripts - -### benchmark_madrona.py - -Benchmarks the Madrona renderer using the Genesis framework. - -**Features:** -- GPU/CPU fallback handling -- Batch rendering support -- Configurable camera and lighting -- Performance profiling integration - -### benchmark_omni.py - -Benchmarks the Omniverse renderer using Isaac Sim. - -**Features:** -- Omniverse-specific settings optimization -- Path tracing and rasterizer modes -- GPU memory management -- Performance-optimized rendering settings - -### benchmark_pyrender.py - -Benchmarks the PyRender renderer. - -**Features:** -- OpenGL-based rendering -- CPU-based simulation -- Cross-platform compatibility - -### benchmark_maniskill.py - -Benchmarks the ManiSkill renderer using the ManiSkill framework. - -**Features:** -- Custom ManiSkill environment implementation -- Support for multiple robot types (Panda, Unitree Go2, Unitree G1) -- Configurable camera modes (minimal for rasterizer, rt-fast for raytracer) -- GPU memory optimization for large batch sizes -- Image saving capabilities for debugging - -**Supported Robots:** -- `panda.xml` → Panda robot -- `go2.xml` → Unitree Go2 robot -- `g1.xml` → Unitree G1 robot - -**Camera Modes:** -- `minimal`: Fast rasterizer mode -- `rt-fast`: Fast raytracer mode - -## Output Structure - -Benchmark runs create the following structure: - -``` -benchmark_reports/ -└── perf_benchmark_YYYYMMDD_HHMMSS/ - ├── perf_data.csv # Raw benchmark data - ├── plots/ # Generated plots - │ ├── panda_plot.png - │ ├── panda_comparison_plot.png - │ └── ... - ├── images/ # Rendered images (ManiSkill) - │ ├── step00_env00_minimal_panda.png - │ └── ... - └── report.html # Interactive HTML report -``` - -### CSV Data Format - -The `perf_data.csv` file contains the following columns: -- `result`: "succeeded" or "failed" -- `mjcf`: Asset file path -- `renderer`: Renderer name -- `rasterizer`: Boolean rasterizer flag -- `n_envs`: Number of environments -- `n_steps`: Number of simulation steps -- `resX`, `resY`: Image resolution -- `camera_posX/Y/Z`: Camera position -- `camera_lookatX/Y/Z`: Camera lookat point -- `camera_fov`: Camera field of view -- `time_taken_gpu`: Total GPU time (seconds) -- `time_taken_per_env_gpu`: GPU time per environment (seconds) -- `time_taken_cpu`: Total CPU time (seconds) -- `time_taken_per_env_cpu`: CPU time per environment (seconds) -- `fps`: Overall FPS -- `fps_per_env`: FPS per environment - -## Advanced Usage - -### Custom Configuration - -Create a custom configuration file: - -```yaml -# custom_config.yml -mjcf_list: - - path/to/your/model.xml - -renderer_list: - - renderer: madrona - benchmark_script: benchmark_madrona.py - timeout: 180 - - renderer: maniskill - benchmark_script: benchmark_maniskill.py - timeout: 240 - -rasterizer_list: [true, false] -batch_size_list: [64, 128, 256] -resolution_list: [[256, 256], [512, 512]] - -raytracer: - max_bounce: 4 - spp: 2 - -simulation: - n_steps: 500 - -camera: - position: [2.0, 1.0, 2.0] - lookat: [0.0, 0.0, 0.0] - fov: 60.0 -``` - -Run with custom config: -```bash -python batch_benchmark.py -f custom_config.yml -``` - -### Asset Preprocessing - -Some renderers may require specific XML formatting. Use the preprocessing utility: - -```bash -# Process a single file -python process_xml.py --file ./genesis/assets/xml/franka_emika_panda/panda.xml - -# Process multiple files -for file in ./genesis/assets/xml/*/*.xml; do - python process_xml.py --file "$file" -done -``` - -### Performance Analysis - -The benchmark suite provides detailed performance analysis: - -1. **Raw Data**: CSV files with detailed timing information -2. **Visualizations**: Plots showing FPS vs batch size relationships -3. **Comparisons**: Side-by-side renderer performance comparisons -4. **HTML Reports**: Interactive reports with embedded plots and tables -5. **Rendered Images**: Sample images for visual verification (ManiSkill) - -### Failure Handling - -The benchmark suite includes robust failure handling: - -- **Timeout Management**: Each renderer has configurable timeouts -- **Progressive Skipping**: If a resolution fails, larger resolutions are skipped -- **Resume Capability**: Can continue from previous runs -- **Error Logging**: Failed runs are recorded with "failed" status - -## Dependencies - -### Required Python Packages -- `pandas`: Data analysis and CSV handling -- `matplotlib`: Plot generation -- `numpy`: Numerical computations -- `pyyaml`: Configuration file parsing -- `torch`: CUDA event handling (for profiling) - -### Renderer-Specific Dependencies -- **Madrona**: Genesis framework -- **Omniverse**: Isaac Sim, Omniverse Kit -- **PyRender**: PyRender, OpenGL -- **ManiSkill**: ManiSkill, SAPIEN, Gymnasium - -## Troubleshooting - -### Common Issues - -1. **CUDA Out of Memory**: Reduce batch sizes or resolutions -2. **Timeout Errors**: Increase timeout values in configuration -3. **Missing Assets**: Ensure MJCF/URDF files are in the correct paths -4. **Renderer Failures**: Check renderer-specific dependencies -5. **XML Compatibility**: Use `process_xml.py` to preprocess assets if needed - -### Debug Mode - -Enable GUI mode for debugging: -```yaml -display: - gui: true -``` - -### Verbose Logging - -Check the console output for detailed timing information and error messages. - -### ManiSkill-Specific Issues - -- **Robot Compatibility**: Ensure robot XML files match supported robot types -- **Memory Configuration**: Adjust GPU memory settings for large batch sizes -- **Image Saving**: Check image output directory permissions - -## Contributing - -To add a new renderer: - -1. Create a new benchmark script (e.g., `benchmark_newrenderer.py`) -2. Implement the required interface (see `benchmark_madrona.py` for reference) -3. Add the renderer to your configuration file -4. Test with the smoke test configuration first - -## License - -This benchmark suite is part of the Genesis project. See the main LICENSE file for details. \ No newline at end of file diff --git a/scripts/perf_benchmark/batch_benchmark.py b/scripts/perf_benchmark/batch_benchmark.py deleted file mode 100644 index 576ebdb4..00000000 --- a/scripts/perf_benchmark/batch_benchmark.py +++ /dev/null @@ -1,385 +0,0 @@ -import argparse -import subprocess -import os -from datetime import datetime - -import pandas as pd - -from benchmark_report_generator import generate_report -from benchmark_configs import BenchmarkConfigs - -# Example command: -# python batch_benchmark.py -f benchmark_config_smoke_test.yml - - -# Create a struct to store the arguments -class BenchmarkArgs: - def __init__( - self, - renderer, - rasterizer, - n_envs, - n_steps, - resX, - resY, - camera_posX, - camera_posY, - camera_posZ, - camera_lookatX, - camera_lookatY, - camera_lookatZ, - camera_fov, - mjcf, - benchmark_result_file, - benchmark_config_file, - max_bounce, - spp, - gui=False, - benchmark_script=None, - renderer_timeout=None, - ): - self.renderer = renderer - self.rasterizer = rasterizer - self.n_envs = n_envs - self.n_steps = n_steps - self.resX = resX - self.resY = resY - self.camera_posX = camera_posX - self.camera_posY = camera_posY - self.camera_posZ = camera_posZ - self.camera_lookatX = camera_lookatX - self.camera_lookatY = camera_lookatY - self.camera_lookatZ = camera_lookatZ - self.camera_fov = camera_fov - self.mjcf = mjcf - self.benchmark_result_file = benchmark_result_file - self.benchmark_config_file = benchmark_config_file - self.max_bounce = max_bounce - self.spp = spp - self.gui = gui - self.benchmark_script = benchmark_script - self.renderer_timeout = renderer_timeout - - @staticmethod - def parse_benchmark_args(): - parser = argparse.ArgumentParser() - parser.add_argument("-d", "--renderer", required=True, type=str) - parser.add_argument("-r", "--rasterizer", action="store_true", default=False) - parser.add_argument("-n", "--n_envs", required=True, type=int) - parser.add_argument("-x", "--resX", required=True, type=int) - parser.add_argument("-y", "--resY", required=True, type=int) - parser.add_argument("-f", "--mjcf", required=True, type=str) - parser.add_argument("-g", "--benchmark_result_file", required=True, type=str) - parser.add_argument("-c", "--benchmark_config_file", required=True, type=str) - args = parser.parse_args() - benchmark_config = BenchmarkConfigs(args.benchmark_config_file) - benchmark_args = BenchmarkArgs( - renderer=args.renderer, - rasterizer=args.rasterizer, - n_envs=args.n_envs, - n_steps=benchmark_config.n_steps, - resX=args.resX, - resY=args.resY, - camera_posX=benchmark_config.camera_pos[0], - camera_posY=benchmark_config.camera_pos[1], - camera_posZ=benchmark_config.camera_pos[2], - camera_lookatX=benchmark_config.camera_lookat[0], - camera_lookatY=benchmark_config.camera_lookat[1], - camera_lookatZ=benchmark_config.camera_lookat[2], - camera_fov=benchmark_config.camera_fov, - mjcf=args.mjcf, - benchmark_result_file=args.benchmark_result_file, - benchmark_config_file=args.benchmark_config_file, - max_bounce=benchmark_config.max_bounce, - spp=benchmark_config.spp, - gui=benchmark_config.gui, - ) - print(f"Benchmark with args:") - print(f" renderer: {benchmark_args.renderer}") - print(f" rasterizer: {benchmark_args.rasterizer}") - print(f" n_envs: {benchmark_args.n_envs}") - print(f" n_steps: {benchmark_args.n_steps}") - print(f" resolution: {benchmark_args.resX}x{benchmark_args.resY}") - print( - f" camera_pos: ({benchmark_args.camera_posX}, {benchmark_args.camera_posY}, {benchmark_args.camera_posZ})" - ) - print( - f" camera_lookat: ({benchmark_args.camera_lookatX}, {benchmark_args.camera_lookatY}, {benchmark_args.camera_lookatZ})" - ) - print(f" camera_fov: {benchmark_args.camera_fov}") - print(f" mjcf: {benchmark_args.mjcf}") - print(f" benchmark_result_file: {benchmark_args.benchmark_result_file}") - print(f" benchmark_config_file: {benchmark_args.benchmark_config_file}") - print(f" max_bounce: {benchmark_args.max_bounce}") - print(f" spp: {benchmark_args.spp}") - print(f" gui: {benchmark_args.gui}") - return benchmark_args - - -class BatchBenchmarkArgs: - def __init__(self, config_file, continue_from): - self.config_file = config_file - self.continue_from = continue_from - - def parse_batch_benchmark_args(): - parser = argparse.ArgumentParser() - parser.add_argument("-f", "--config_file", type=str, default="benchmark_config_smoke_test.yml") - parser.add_argument("-c", "--continue_from", type=str, default=None) - args = parser.parse_args() - return BatchBenchmarkArgs(config_file=args.config_file, continue_from=args.continue_from) - - -def create_batch_args(benchmark_result_file, config_file): - # Ensure the directory exists - os.makedirs(os.path.dirname(benchmark_result_file), exist_ok=True) - - # Load configuration - config = BenchmarkConfigs(config_file) - mjcf_list = config.mjcf_list - renderer_list = config.renderer_list - rasterizer_list = config.rasterizer_list - batch_size_list = config.batch_size_list - resolution_list = config.resolution_list - n_steps = config.n_steps - camera_pos = config.camera_pos - camera_lookat = config.camera_lookat - camera_fov = config.camera_fov - max_bounce = config.max_bounce - spp = config.spp - gui = config.gui - - # Batch data for resolution and batch size needs to be sorted in ascending order of resX x resY - # so that if one resolution fails, all the resolutions, which are larger, will be skipped. - resolution_list.sort(key=lambda x: x[0] * x[1]) - - # Create a hierarchical dictionary to store all combinations - batch_args_dict = {} - - # Build hierarchical structure - for renderer_info in renderer_list: - renderer = renderer_info["renderer"] - benchmark_script = renderer_info["benchmark_script"] - renderer_timeout = renderer_info["timeout"] - batch_args_dict[renderer] = {} - for rasterizer in rasterizer_list: - batch_args_dict[renderer][rasterizer] = {} - for mjcf in mjcf_list: - batch_args_dict[renderer][rasterizer][mjcf] = {} - for batch_size in batch_size_list: - batch_args_dict[renderer][rasterizer][mjcf][batch_size] = {} - for resolution in resolution_list: - resX, resY = resolution - # Create benchmark args for this combination - args = BenchmarkArgs( - renderer=renderer, - rasterizer=rasterizer, - n_envs=batch_size, - n_steps=n_steps, - resX=resX, - resY=resY, - camera_posX=camera_pos[0], - camera_posY=camera_pos[1], - camera_posZ=camera_pos[2], - camera_lookatX=camera_lookat[0], - camera_lookatY=camera_lookat[1], - camera_lookatZ=camera_lookat[2], - camera_fov=camera_fov, - mjcf=mjcf, - benchmark_result_file=benchmark_result_file, - benchmark_config_file=config_file, - max_bounce=max_bounce, - spp=spp, - gui=gui, - benchmark_script=benchmark_script, - renderer_timeout=renderer_timeout, - ) - batch_args_dict[renderer][rasterizer][mjcf][batch_size][(resX, resY)] = args - - return batch_args_dict - - -def create_benchmark_result_file(continue_from): - benchmark_report_root = os.path.join(os.path.dirname(os.path.abspath(__file__)), "benchmark_reports") - if continue_from is not None: - continue_from_file = os.path.join(benchmark_report_root, continue_from, "perf_data.csv") - if not os.path.exists(continue_from_file): - raise FileNotFoundError(f"Continue from file not found: {continue_from_file}") - print(f"Continuing from file: {continue_from_file}") - return continue_from_file - else: - # Create benchmark result data file with header - benchmark_timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") - - # Get the benchmark project root directory - benchmark_data_directory = os.path.join(benchmark_report_root, f"perf_benchmark_{benchmark_timestamp}") - - if not os.path.exists(benchmark_data_directory): - os.makedirs(benchmark_data_directory) - - benchmark_result_file = f"{benchmark_data_directory}/perf_data.csv" - with open(benchmark_result_file, "w") as f: - f.write( - "result,mjcf,renderer,rasterizer,n_envs,n_steps,resX,resY,camera_posX,camera_posY,camera_posZ,camera_lookatX,camera_lookatY,camera_lookatZ,camera_fov,time_taken_gpu,time_taken_per_env_gpu,time_taken_cpu,time_taken_per_env_cpu,fps,fps_per_env\n" - ) - print(f"Created new benchmark result file: {benchmark_result_file}") - return benchmark_result_file - - -def write_benchmark_result_file(args: BenchmarkArgs, performance_results: dict): - os.makedirs(os.path.dirname(args.benchmark_result_file), exist_ok=True) - with open(args.benchmark_result_file, "a") as f: - f.write( - f"succeeded,{args.mjcf},{args.renderer}," - f"{args.rasterizer},{args.n_envs},{args.n_steps}," - f"{args.resX},{args.resY}," - f"{args.camera_posX},{args.camera_posY},{args.camera_posZ}," - f"{args.camera_lookatX},{args.camera_lookatY},{args.camera_lookatZ}," - f"{args.camera_fov}," - f"{performance_results['time_taken_gpu']},{performance_results['time_taken_per_env_gpu']},{performance_results['time_taken_cpu']}," - f"{performance_results['time_taken_per_env_cpu']},{performance_results['fps']},{performance_results['fps_per_env']}\n" - ) - - -def get_previous_runs(continue_from_file): - if continue_from_file is None: - return [] - - # Read the existing benchmark data file - df = pd.read_csv(continue_from_file) - - # Create a list of tuples containing run info and status - previous_runs = [] - - for _, row in df.iterrows(): - run_info = ( - row["mjcf"], - row["renderer"], - row["rasterizer"], - row["n_envs"], - (row["resX"], row["resY"]), - row["result"], # 'succeeded' or 'failed' - ) - previous_runs.append(run_info) - - return previous_runs - - -def run_batch_benchmark(batch_args_dict, previous_runs=None): - if previous_runs is None: - previous_runs = [] - - for renderer in batch_args_dict: - print(f"Running benchmark for {renderer}") - for rasterizer in batch_args_dict[renderer]: - for mjcf in batch_args_dict[renderer][rasterizer]: - for batch_size in batch_args_dict[renderer][rasterizer][mjcf]: - last_resolution_failed = False - for resolution in batch_args_dict[renderer][rasterizer][mjcf][batch_size]: - if last_resolution_failed: - break - - # Check if this run was in a previous execution - run_info = (mjcf, renderer, rasterizer, batch_size, resolution) - skip_this_run = False - - for prev_run in previous_runs: - if run_info == prev_run[:5]: # Compare only the run parameters, not the status - skip_this_run = True - if prev_run[4] == "failed": - # Skip this and subsequent resolutions if it failed before - last_resolution_failed = True - break - - if skip_this_run: - continue - - # Run the benchmark - batch_args = batch_args_dict[renderer][rasterizer][mjcf][batch_size][resolution] - - # launch a process to run the benchmark - current_dir = os.path.dirname(os.path.abspath(__file__)) - benchmark_script_path = os.path.join(current_dir, batch_args.benchmark_script) - if not os.path.exists(benchmark_script_path): - raise FileNotFoundError(f"Benchmark script not found: {benchmark_script_path}") - cmd = ["python3", benchmark_script_path] - if batch_args.rasterizer: - cmd.append("--rasterizer") - cmd.extend( - [ - "--renderer", - batch_args.renderer, - "--n_envs", - str(batch_args.n_envs), - "--resX", - str(batch_args.resX), - "--resY", - str(batch_args.resY), - "--mjcf", - batch_args.mjcf, - "--benchmark_result_file", - batch_args.benchmark_result_file, - "--benchmark_config_file", - batch_args.benchmark_config_file, - ] - ) - try: - # Read timeout from config - process = subprocess.Popen(cmd) - try: - # Hack to avoid omniverse runs to take forever. - timeout = batch_args.renderer_timeout - return_code = process.wait(timeout=timeout) - if return_code != 0: - raise subprocess.CalledProcessError(return_code, cmd) - except subprocess.TimeoutExpired: - process.kill() - process.wait() # Wait for the process to be killed - raise TimeoutError(f"Process did not complete within {timeout} seconds") - except Exception as e: - print(f"Error running benchmark: {str(e)}") - if isinstance(e, subprocess.CalledProcessError): - last_resolution_failed = True - # Write failed result without timing data - with open(batch_args.benchmark_result_file, "a") as f: - f.write( - f"failed,{batch_args.mjcf},{batch_args.renderer},{batch_args.rasterizer},{batch_args.n_envs},{batch_args.n_steps},{batch_args.resX},{batch_args.resY},{batch_args.camera_posX},{batch_args.camera_posY},{batch_args.camera_posZ},{batch_args.camera_lookatX},{batch_args.camera_lookatY},{batch_args.camera_lookatZ},{batch_args.camera_fov},,,,,,\n" - ) - - -def sort_and_dedupe_benchmark_result_file(benchmark_result_file): - # Sort by mjcf asc, renderer asc, rasterizer desc, n_envs asc, resX asc, resY asc, n_envs asc - df = pd.read_csv(benchmark_result_file) - df = df.sort_values( - by=["mjcf", "renderer", "rasterizer", "resX", "resY", "n_envs", "result"], - ascending=[True, True, False, True, True, True, False], - ) - - # Deduplicate by keeping the first occurrence of each unique combination of mjcf, renderer, rasterizer, resX, resY, n_envs - # Keep succeeded runs if there are multiple runs for the same combination. - df = df.drop_duplicates( - subset=["mjcf", "renderer", "rasterizer", "resX", "resY", "n_envs"], - keep="first", - ) - df.to_csv(benchmark_result_file, index=False) - - -def main(): - batch_benchmark_args = BatchBenchmarkArgs.parse_batch_benchmark_args() - benchmark_result_file = create_benchmark_result_file(batch_benchmark_args.continue_from) - - # Get list of previous runs if continuing from a previous run - previous_runs = get_previous_runs(benchmark_result_file) - - # Run benchmark in batch - batch_args_dict = create_batch_args(benchmark_result_file, config_file=batch_benchmark_args.config_file) - run_batch_benchmark(batch_args_dict, previous_runs) - - # Sort benchmark result file - sort_and_dedupe_benchmark_result_file(benchmark_result_file) - - # Generate plots - generate_report(benchmark_result_file, config_file=batch_benchmark_args.config_file) - - -if __name__ == "__main__": - main() diff --git a/scripts/perf_benchmark/benchmark_assets/plane_urdf/checker.png b/scripts/perf_benchmark/benchmark_assets/plane_urdf/checker.png deleted file mode 100644 index 7c57d0414b983e0366cc8de92dcae86504929520..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1902 zcmeAS@N?(olHy`uVBq!ia0y~yU;;9k7&t&wwUqN(1_pLhPZ!6KiaBquI|{Zq2(TP{ z%p|f5c$!U<>Us~8xJSr`<&7#J3fQlmjInhNNd z5zg)X_JvL2^PclxSR@`ZGPrOuI80#}rAC8bG!@V{Bh=pOWqgTe~ HDWM4feGfs< diff --git a/scripts/perf_benchmark/benchmark_assets/plane_urdf/plane.mtl b/scripts/perf_benchmark/benchmark_assets/plane_urdf/plane.mtl deleted file mode 100644 index 12fb0655..00000000 --- a/scripts/perf_benchmark/benchmark_assets/plane_urdf/plane.mtl +++ /dev/null @@ -1,14 +0,0 @@ -newmtl Material - Ns 10.0000 - Ni 1.5000 - d 1.0000 - Tr 0.0000 - Tf 1.0000 1.0000 1.0000 - illum 2 - Ka 0.0000 0.0000 0.0000 - Kd 1.0000 1.0000 1.0000 - Ks 0.0000 0.0000 0.0000 - Ke 0.0000 0.0000 0.0000 - map_Ka cube.tga - map_Kd checker.png - diff --git a/scripts/perf_benchmark/benchmark_assets/plane_urdf/plane.urdf b/scripts/perf_benchmark/benchmark_assets/plane_urdf/plane.urdf deleted file mode 100644 index 448eabee..00000000 --- a/scripts/perf_benchmark/benchmark_assets/plane_urdf/plane.urdf +++ /dev/null @@ -1,26 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/scripts/perf_benchmark/benchmark_assets/plane_urdf/plane100.obj b/scripts/perf_benchmark/benchmark_assets/plane_urdf/plane100.obj deleted file mode 100644 index 3a74f590..00000000 --- a/scripts/perf_benchmark/benchmark_assets/plane_urdf/plane100.obj +++ /dev/null @@ -1,22 +0,0 @@ -# Blender v2.66 (sub 1) OBJ File: '' -# www.blender.org -mtllib plane.mtl -o Plane -v 100.000000 -100.000000 0.000000 -v 100.000000 100.000000 0.000000 -v -100.000000 100.000000 0.000000 -v -100.000000 -100.000000 0.000000 - -vt 100.000000 0.000000 -vt 100.000000 100.000000 -vt 0.000000 100.000000 -vt 0.000000 0.000000 - - - -usemtl Material -s off -f 1/1 2/2 3/3 -f 1/1 3/3 4/4 - - diff --git a/scripts/perf_benchmark/benchmark_assets/plane_usd/.asset_hash b/scripts/perf_benchmark/benchmark_assets/plane_usd/.asset_hash deleted file mode 100644 index c2288d4a..00000000 --- a/scripts/perf_benchmark/benchmark_assets/plane_usd/.asset_hash +++ /dev/null @@ -1 +0,0 @@ -b3f970c53c90c6563970b4d47b8e0bab \ No newline at end of file diff --git a/scripts/perf_benchmark/benchmark_assets/plane_usd/config.yaml b/scripts/perf_benchmark/benchmark_assets/plane_usd/config.yaml deleted file mode 100644 index 22459db1..00000000 --- a/scripts/perf_benchmark/benchmark_assets/plane_usd/config.yaml +++ /dev/null @@ -1,18 +0,0 @@ -asset_path: genesis/assets/urdf/plane/plane.urdf -usd_dir: null -usd_file_name: null -force_usd_conversion: true -make_instanceable: true -fix_base: true -root_link_name: null -link_density: 0.0 -merge_fixed_joints: true -convert_mimic_joints_to_normal_joints: false -joint_drive: null -collider_type: convex_hull -self_collision: false -replace_cylinders_with_capsules: false -collision_from_visuals: false -## -# Generated by UrdfConverter on 2025-06-10 at 18:41:48. -## diff --git a/scripts/perf_benchmark/benchmark_assets/plane_usd/configuration/materials/textures/checker.png b/scripts/perf_benchmark/benchmark_assets/plane_usd/configuration/materials/textures/checker.png deleted file mode 100644 index 7c57d0414b983e0366cc8de92dcae86504929520..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1902 zcmeAS@N?(olHy`uVBq!ia0y~yU;;9k7&t&wwUqN(1_pLhPZ!6KiaBquI|{Zq2(TP{ z%p|f5c$!U<>Us~8xJSr`<&7#J3fQlmjInhNNd z5zg)X_JvL2^PclxSR@`ZGPrOuI80#}rAC8bG!@V{Bh=pOWqgTe~ HDWM4feGfs< diff --git a/scripts/perf_benchmark/benchmark_assets/plane_usd/configuration/plane_base.usd b/scripts/perf_benchmark/benchmark_assets/plane_usd/configuration/plane_base.usd deleted file mode 100644 index c73a418db54528909933ad361159b0f30010cdae..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 4313 zcmbtXeQaCR6+icV#CF`oc4If8G{jAt^dmG)>U7Io5YI_W8)zFl_A3F_g%|rJdDMQ- zypJqpotD4Q{+ZMQ+StalYyydWXsuK>F>RoV2|=}Lv}7=fU}C@+u%Wr-V+kbo&VBZ4 zQYZsyC;7d5-}$=do_p@~eTk9bZDMMmAKYM5z+Y+q-zQgfz+>w|Q1>1LK~SnkIDOxW z{^}L|2JBONN(A9e&S_(FrBv>wrl*npr8HX_;( z?Fh&5ChV^abThuQ2ufQJTM-?I0D{ssL?>c9q6^WD*nx;3dJsDiUqXBt(Tn&BB8u3B zxDIhW!b#jGC+y6D#(!x-*tY|u?J^Z_aQc@i);Qct?Xu#fDFZz7WCgRQu)^`Etr=dMH*7MniY(X-nnKJC#*S4+x=eP4-wcJ(L_hYV%1wnkt7!b*b&FZP0Sb zZl!+^*4>B}pX(nIf7ng+KJDKvRMx)*s9#E#`LA$n|DTr4;HrFqv7w@OrLm9DB*2yD zR*8rG+_~8Nry*El=7T4`gATT^L6z9SNyiCFO;80 z`#)=K9<=w)mDk2DEeHyqy>3B)b%eDT&83%iJ2cz1-Lq@{$x6J-^qbJ>MO87OkdssyvhuiO<;;YxVi&q&p!C}T=aF%P31D(I$yE=I;RJ)eS^ z(JmO%reQh&=1^fs$5HCw;i#!gs*#gSc{RwIuE?tCgQCmGNI4k_mR`^d8Tzd;8RTiR z6(WXm_dU?v-PNVwIlqsGk6O`GSEO8@qGlC!B9<5gsi34XlX70d@HIs>4H%a)@&OD- zp6=JI0Hg*{H{&7~xG70DqJ{~EHI`K{35uo;LPUoAIJ8E`CP0S0ns&(eCKL&1mRYb& zBbv|Vq6S6=o**2}Dh6gVBj^7J?rktTrRA)=45>+JGvU_4IkW*q9oM2tzL47qVafas z3~QF2k=Fvb6Nn%BV}>Db2X6@OMXzInSM4FPMx5v?F)aOIu`t`T8H7cy0pj3E(=R$e5G5Y)opo zEa>vMj1{qo2`G&uoRR0@ei+Ybk{P-Epu$e$oXy)0!uQBYQ#*u(gK1Wzo$R@xys9Mn zhC4BZApMT8t04in{u76oi=Ju7D=K6x!_@Kvk|{wfHzsE_h%;vo@76V|0DY1nhx>m? zxF#Tvu;dAnj*LU+_LIy8W4lG>jP1DSnM1PPSx_g^kO}XT3J^a|cn`26fJSZM!KVqi zh=mjHC9A_>xK~rLB()q29VJ{d_Vj|)JI18=e z;l~)x2Z7Wx9O)#X9UDeI?4j@Jlw;^svd=+sJ@ioL4B7?{xWW)qw z5}_dOMC7V1HgGq6P%7F_QH+PgbW%*DMFRdL55;YwDDr8-vxMOYvsAN$|LjL>L-8Y) z#ZC_?KihMr=S=V7nI7*4bEz7n(ef$#Af5Hb9XsDnb9fAa(1B7-;Bt8nu-lR4Cv zpT&p$*U8u;1B4lz{RyuC_OH7~;C+}0uQ1|c_X@qN2mCEOkXIq#MXOrjR@C*LpdRb|)V|)0m5g`8B8j*KUlTMu+63?$nn70Z z)49|NqjpqxxFH8WLgZRL!y|(S{6fMG87bpf*4N+=zn2wXU7>}cp7>^~BNim+p5{E^8p65Q~ zIp%%deUbMp;G1iHo?rDY&w2mF`~JxX%FNq*!#{W~@OPeTc%A2JE?}Q~t>xAArN6bl zva$S1+xg9<^II3scbw~5;^zO-QL}iiXNf!AyWoDRmn|WfrKfIK_}uveXJ2Z6>6S-seYkz+LkE94`tCsYI~V!!wx-XY%1=eUeY2>OeiS^F^x%)cfH-ry}*>2O>^aqZ*@F> z?dkdQ62Da5+WMgVNhsZ^S0OG`Cn#^jE z1B4QBf4T1cGAWZ%y$gRQ7T&6d8A3{ga-6Brk5ijw>;=ZJTsJ+e{FRF}uqB3B;^K}{ z(MKAu1>)bvqPy|X&@0x?{Q?5b+4UcfY+l2E%zX$x+eeuRF-H9}Po>Kex_fzmo3iz+~^*52pl!DiR& zu1g{i3gU*4fJ?b>$RE%{#RYC$df-3Kv?MekIIX@1{W<8EN*-%x~Vj znSJx7d}?C9F*#a-1idA0dw8NImvV zlbj*W#)eI#^bxlaOWI8QDDf8Jt;BJVZ6s%7?!uq_v}VkUB^lAGMR@ zUBtVI_Ykjzml4+#>8-{GC)Oh>DgUPVBZnfx^{Bqhah$Zlk^zo&C7a{5r3Sn&2MF({sM4X7O$?SLY|p8D2nx zR|E1Nc@v+F2&ZZ{EbgQJ3_c#I37Zn-Rs;A1f2nSrdx0R(bKT&~Yp!tq#OQJ1ydL1v zgg7g#XI*QqP##M|f6?^)Zk#vkVzN5TXH1T#W=y{e3zjg?x{hg&IRY8;eSA&@I8{+M z?ZWH=^eT6P35L^P--7L6fsa!)*PD3@3A%=P+|)c?ql}y?Wg#3tFiA2iHs1&HsF>x} z^WQ-&u=xVzS#8+nUZzh_t|i%0{j=0w)P7I0R0hK%+<66tHOcMTmrS{ajy+iLUHcsO z%C0Xs3S(5cgW;mvIL*2bqV2<%xb0R&Fn17WrbX2ssZg~!oHIRPR)kFm0@FLogR&V+ z(+|9g!F=r<#Y4?@%^;sdli`hq8Prg5U7NBG)*goJEZJqU5hdkX3otHUfx*!1%Z!hX zs!U;u0##A5+;wj`p=s<7?cOHZxwN>lw79s4U*nC`_Y4<1bzq*}LP|f?jRFqA(vZtR zqi?7Fwubx=KG49Dggv2mr=<1M(0W0`#xFGVenp;HTn=qFNPjHc`9Oo7MF&e^`wk?b zgXse@ey5)AmHwl&dnk+CKK<1m4GXH?dXK7JjEbe(ZBa8XAy!V5j55ol4I^!gr%M^! zP2SP|)|6WtZg$DZllpOIsGVUG3AZWPcv`Y4C?#mv<@O%NZppUi(`r z-FdqVUNCxl6G$eOA0+Q4mD_5&98DoLJ6DN}DZ@yxlzMgd?O!%`eXX#S!?!P~a-OxE zQgShjOu4YC-G8v6tgf=;-4(`GHntqXSsIEL!&-WztjM7mQd5<4$Dob?l1f{f($=1k z(>A1L_)VaYMsiiTle`lT#;KDpluk@WUbHO8?L@g~K21(djGZ|4(BYeCxP^z4-_fzs x@zIAcu0;5OC{7D6O~o$VvY&~J)`D_j>bV$`CM&{cBYd)4Dz3+WjPR)l{|7{fH*o*} diff --git a/scripts/perf_benchmark/benchmark_assets/plane_usd/configuration/plane_sensor.usd b/scripts/perf_benchmark/benchmark_assets/plane_usd/configuration/plane_sensor.usd deleted file mode 100644 index 5f117bb344c827ff09a089100d5c4bc0956b8573..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 645 zcmbtRu}*_f6g?oQ4JO9T!NIkS-Ax>XP^yilC51-fRAOHv2~a|5VmB9eKcS!C5BL?1 z{)DqJsSmg}?c(GeI4}2{doJ%hHyE}@UdP5hs(M#y{O^}-bZKOenNN7f=3mZ5y9wN5 zky%gC9Em7IK6gbvN}~c<9Hs(U9wqi;6i;%IVj`yDA}(Cn!#ooY(KHe{g6S+zP%Jai z4->)W*rgM%$7}ghN7|06p0Vgug@QN31fDN}h8Y~fLKC`7;}}q}YMG5nYnT)e4tRD| z+9?ix^lz1VjI71D(r~!ISSx3gJ@#NKv?sR&yp6s?g{a(9*8l(j diff --git a/scripts/perf_benchmark/benchmark_assets/plane_usd/plane.usd b/scripts/perf_benchmark/benchmark_assets/plane_usd/plane.usd deleted file mode 100644 index 44550cfca8973b3ad5ded5f79ec300694a10a7c2..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1532 zcmbtU&u>Bc)QMKIzT-@xAwE z_RX6ut*y@0D$7fVqDLQx`||&;+E4*T`mjBKIwuCY(mE$ptfCn4NKXS2BqxZI#Bce& ze;56lAwOWZy#KOCCOEK?s@Slt4yJsnSAqX+()SJ78PeY+&IG%Sk`W<(lLj5w2~-KY z1qM512;Wb&LmuR{9i>AOPm)sL2?$vBlqVCvDbED-M z(sb-2u4UN#oZz-_yboKVF$?X2d%i8qy2#+VXsw?$+LSzo?~2W);8NfWI)~tT=H}9Q z(`tC!MuRtvmL*H%gK&A>Y?|D|TGR11A=@rrF*Z3aCLoJ$(Q9yT9M_LY&#;AM$XT?G zIiAUFnZOF- zMdc;5cTlorh0Ci*idjsq?MF%+z`VulAF@*wdHv7&5W&}&Gji<>S?mus~U8(}OQ4YAPc@RP`^2wsi-^+OOy!^&_st^=3p zLB;f^3FNVW?I;#jDCUo1>J$}?>pzlf0;wqUxXxG>*|e^9OY7+vGI2eHpMVeahrOf4FCXejfTpca zowOQ4HicTPR<4E_UXT8xbd9(}lCk0~l9){J|S| lp&Epo^M8n+_;@K_{XD>w>!yzveY{dCEDht2ef$d_{|BZy(DDEP diff --git a/scripts/perf_benchmark/benchmark_configs.py b/scripts/perf_benchmark/benchmark_configs.py deleted file mode 100644 index 78a9f434..00000000 --- a/scripts/perf_benchmark/benchmark_configs.py +++ /dev/null @@ -1,45 +0,0 @@ -import os -import yaml - - -class BenchmarkConfigs: - def __init__(self, config_file): - self.load_from_config_file(config_file) - - def load_from_config_file(self, config_file): - self.config_path = os.path.join(os.path.dirname(__file__), "configs", config_file) - if not os.path.exists(self.config_path): - raise FileNotFoundError(f"Config file not found: {self.config_path}") - with open(self.config_path, "r") as f: - config = yaml.safe_load(f) - - self.mjcf_list = config["mjcf_list"] - self.rasterizer_list = config["rasterizer_list"] - self.batch_size_list = config["batch_size_list"] - self.resolution_list = config["resolution_list"] - self.gui = config.get("gui", False) - - # Get renderer list with defaults - self.renderer_list = config["renderer_list"] - - # Get raytracer config with defaults - raytracer_config = config.get("raytracer", {}) - self.max_bounce = raytracer_config.get("max_bounce", 2) - self.spp = raytracer_config.get("spp", 1) - - # Get simulation config with defaults - simulation_config = config.get("simulation", {}) - self.n_steps = simulation_config.get("n_steps", 1) - - # Get camera config with defaults - camera_config = config.get("camera", {}) - self.camera_pos = camera_config.get("position", [1.5, 0.5, 1.5]) - self.camera_lookat = camera_config.get("lookat", [0.0, 0.0, 0.5]) - self.camera_fov = camera_config.get("fov", 45.0) - - # Get display config with defaults - display_config = config.get("display", {}) - self.gui = display_config.get("gui", False) - - # Get comparison list with defaults - self.comparison_list = config.get("comparison_list", []) diff --git a/scripts/perf_benchmark/benchmark_madrona.py b/scripts/perf_benchmark/benchmark_madrona.py deleted file mode 100644 index dbadb69e..00000000 --- a/scripts/perf_benchmark/benchmark_madrona.py +++ /dev/null @@ -1,141 +0,0 @@ -import os - -import genesis as gs -from genesis.utils.image_exporter import FrameImageExporter - -from batch_benchmark import BenchmarkArgs, write_benchmark_result_file -from benchmark_profiler import BenchmarkProfiler - - -def init_gs(benchmark_args): - ########################## init ########################## - try: - gs.init(backend=gs.gpu) - except Exception as e: - print(f"Failed to initialize GPU backend: {e}") - print("Falling back to CPU backend") - gs.init(backend=gs.cpu) - - ########################## create a scene ########################## - scene = gs.Scene( - viewer_options=gs.options.ViewerOptions( - camera_pos=( - benchmark_args.camera_posX, - benchmark_args.camera_posY, - benchmark_args.camera_posZ, - ), - camera_lookat=( - benchmark_args.camera_lookatX, - benchmark_args.camera_lookatY, - benchmark_args.camera_lookatZ, - ), - camera_fov=benchmark_args.camera_fov, - ), - show_viewer=False, - renderer=gs.options.renderers.BatchRenderer( - use_rasterizer=benchmark_args.rasterizer, - ), - ) - - ########################## entities ########################## - plane = scene.add_entity( - gs.morphs.Plane(), - ) - franka = scene.add_entity( - gs.morphs.MJCF(file=benchmark_args.mjcf), - visualize_contact=False, - ) - - ########################## cameras ########################## - cam_0 = scene.add_camera( - res=(benchmark_args.resX, benchmark_args.resY), - pos=( - benchmark_args.camera_posX, - benchmark_args.camera_posY, - benchmark_args.camera_posZ, - ), - lookat=( - benchmark_args.camera_lookatX, - benchmark_args.camera_lookatY, - benchmark_args.camera_lookatZ, - ), - fov=benchmark_args.camera_fov, - ) - scene.add_light( - pos=(0.0, 0.0, 1.5), - dir=(1.0, 1.0, -2.0), - directional=True, - castshadow=False, - cutoff=45.0, - intensity=0.5, - ) - scene.add_light( - pos=(4, -4, 4), - dir=(-1, 1, -1), - directional=False, - castshadow=False, - cutoff=45.0, - intensity=0.5, - ) - ########################## build ########################## - scene.build(n_envs=benchmark_args.n_envs) - return scene - - -def run_benchmark(scene, benchmark_args): - try: - n_envs = benchmark_args.n_envs - n_steps = benchmark_args.n_steps - - # warmup - scene.step() - rgb, depth, _, _ = scene.render_all_cameras(rgb=True, depth=True) - - # Profiler - profiler = BenchmarkProfiler(n_steps, n_envs) - output_dir = os.path.dirname(benchmark_args.benchmark_result_file) - os.makedirs(output_dir, exist_ok=True) - image_dirname = f"{benchmark_args.renderer}-{benchmark_args.rasterizer}-{benchmark_args.n_envs}-{benchmark_args.resX}" - image_dir = os.path.join(output_dir, image_dirname) - if n_steps < 10: - exporter = FrameImageExporter(image_dir) - - for i in range(n_steps): - profiler.on_simulation_start() - scene.step() - profiler.on_rendering_start() - rgb, depth, _, _ = scene.render_all_cameras(rgb=True, depth=True) - profiler.on_rendering_end() - if n_steps < 10: - exporter.export_frame_all_cameras(i, rgb=rgb) - profiler.end() - profiler.print_summary() - - performance_results = { - "time_taken_gpu": profiler.get_total_rendering_gpu_time(), - "time_taken_cpu": profiler.get_total_rendering_cpu_time(), - "time_taken_per_env_gpu": profiler.get_total_rendering_gpu_time_per_env(), - "time_taken_per_env_cpu": profiler.get_total_rendering_cpu_time_per_env(), - "fps": profiler.get_rendering_fps(), - "fps_per_env": profiler.get_rendering_fps_per_env(), - } - write_benchmark_result_file(benchmark_args, performance_results) - - except Exception as e: - print(f"Error during benchmark: {e}") - raise - - -def main(): - ######################## Parse arguments ####################### - benchmark_args = BenchmarkArgs.parse_benchmark_args() - - ######################## Initialize scene ####################### - scene = init_gs(benchmark_args) - - ######################## Run benchmark ####################### - run_benchmark(scene, benchmark_args) - - -if __name__ == "__main__": - main() diff --git a/scripts/perf_benchmark/benchmark_maniskill.py b/scripts/perf_benchmark/benchmark_maniskill.py deleted file mode 100644 index aa283e46..00000000 --- a/scripts/perf_benchmark/benchmark_maniskill.py +++ /dev/null @@ -1,209 +0,0 @@ -from typing import Dict -import numpy as np -import torch -import os -from PIL import Image - -import gymnasium as gym -import sapien -from mani_skill.envs.sapien_env import BaseEnv -from mani_skill.utils.structs.types import GPUMemoryConfig, SimConfig -from mani_skill.sensors.camera import CameraConfig -from mani_skill.utils import sapien_utils -from mani_skill.utils.registration import register_env -from mani_skill.utils.wrappers.flatten import FlattenActionSpaceWrapper - -from batch_benchmark import BenchmarkArgs, write_benchmark_result_file -from benchmark_profiler import BenchmarkProfiler, get_utilization_percentages, print_system_utilization - - -# Get asset directory from environment variable -asset_dir = os.path.abspath(os.getenv("ASSET_DIR")) -benchmark_dir = os.path.abspath(os.path.dirname(__file__)) - - -@register_env("SingleRobotBenchmark-v1") -class SingleRobotBenchmarkEnv(BaseEnv): - SUPPORTED_REWARD_MODES = ["none"] - SUPPORTED_ROBOTS = ["panda", "unitree_go2", "unitree_g1"] - - def __init__( - self, - *args, - robot_uid="panda", - camera_mode="minimal", - camera_width=128, - camera_height=128, - camera_fov=0.7854, # math.radian(45) - camera_pos=(1.5, 0.5, 1.5), - camera_lookat=(0.0, 0.0, 0.5), - **kwargs, - ): - self.camera_mode = camera_mode - self.camera_width = camera_width - self.camera_height = camera_height - self.camera_pos = camera_pos - self.camera_lookat = camera_lookat - self.camera_fov = camera_fov - super().__init__(*args, robot_uids=robot_uid, **kwargs) - - @property - def _default_sensor_configs(self): - return [ - CameraConfig( - uid="render_camera", - pose=sapien_utils.look_at(self.camera_pos, self.camera_lookat), - width=self.camera_width, - height=self.camera_height, - fov=self.camera_fov, - shader_pack=self.camera_mode, - ), - ] - - @property - def _default_human_render_camera_configs(self): - return dict() - - def _load_agent(self, options: dict): - super()._load_agent(options, sapien.Pose(p=[0, 0, 0], q=[1, 0, 0, 0])) - - def _load_scene(self, options: dict): - ground_path = os.path.join(benchmark_dir, "benchmark_assets/plane_urdf/plane.urdf") - urdf_loader = self.scene.create_urdf_loader() - urdf_loader.fix_root_link = True - ground_actor = urdf_loader.parse(ground_path)["actor_builders"][0] - ground_actor._auto_inertial = True # Force ground urdf to be static! - self.ground = ground_actor.build_static(name="ground") - - def _initialize_episode(self, env_idx: torch.Tensor, options: dict): - with torch.device(self.device): - qpos = np.zeros(len(self.agent.robot.get_active_joints())) - self.agent.robot.set_qpos(qpos) - - def _load_lighting(self, options: Dict): - self.scene.add_directional_light( # norm([1.0, 1.0, -2.0] - [0, 0, 1.5]) - [0.26490647, 0.26490647, -0.92717265], - [3.0, 3.0, 3.0], - shadow=False, - ) - - def _get_obs_extra(self, info: Dict): - return dict() - - def evaluate(self): - return {} - - -def main(): - args = BenchmarkArgs.parse_benchmark_args() - - env_id = "SingleRobotBenchmark-v1" - n_envs = args.n_envs - n_steps = args.n_steps - sim_config = SimConfig( - gpu_memory_config=GPUMemoryConfig( - max_rigid_contact_count=n_envs * max(1024, n_envs) * 80, - max_rigid_patch_count=n_envs * max(1024, n_envs) * 4, - found_lost_pairs_capacity=2**26, - ), - sim_freq=100, # dt = 0.01 - control_freq=100, # substep = 1 - spacing=10.0, - ) - - if args.mjcf.endswith("panda.xml"): - robot_uid = "panda" - elif args.mjcf.endswith("go2.xml"): - robot_uid = "unitree_go2" - elif args.mjcf.endswith("g1.xml"): - robot_uid = "unitree_g1" - else: - raise Exception(f"Invalid robot: {args.mjcf}") - - if args.rasterizer: - camera_mode = "minimal" - else: - camera_mode = "rt-fast" - obs_mode = "rgbd" # Or "rgb" - # render_mode = "sensors" - render_mode = "rgb_array" # "human for GUI" - env = gym.make( - env_id, - num_envs=n_envs, - obs_mode=obs_mode, - render_mode=render_mode, - control_mode="pd_joint_delta_pos", - sim_config=sim_config, - robot_uid=robot_uid, - camera_mode=camera_mode, - camera_width=args.resX, - camera_height=args.resY, - # parallel_in_single_scene=True, # This actually combines all environments into a single env, is it batch rendering? - ) - if isinstance(env.action_space, gym.spaces.Dict): - env = FlattenActionSpaceWrapper(env) - base_env: BaseEnv = env.unwrapped - base_env.print_sim_details() - - # Build image output directory similar to _omni - output_dir = os.path.dirname(args.benchmark_result_file) - os.makedirs(output_dir, exist_ok=True) - image_dirname = f"{args.renderer}-{args.rasterizer}-{args.n_envs}-{args.resX}" - image_dir = os.path.join(output_dir, image_dirname) - image_tiles = [] - - profiler = BenchmarkProfiler(n_steps, n_envs) - with torch.inference_mode(): - env.reset(seed=2022) - for i in range(3): - env.step(None) # warmup step - env.render() - env.reset(seed=2022) - for i in range(n_steps): - # obs, rew, terminated, truncated, info = env.step(actions) - print(f"Step: {i}") - profiler.on_simulation_start() - profiler.on_rendering_start() - obs = env.step(None)[0] - # When render_mode="sensors", speed of env.render() suffers a great decline in large batch size. - profiler.on_rendering_end() - if render_mode == "human": - viewer = env.render() - image_tile = None - else: - image_tile = env.render() - image_tile = obs["sensor_data"]["render_camera"]["rgb"] - - if n_steps < 10: - image_tiles.append(image_tile) - - if i % 10 == 0: - system_analysis = get_utilization_percentages() - print_system_utilization(system_analysis) - - profiler.end() - profiler.print_summary() - if n_steps < 10: - os.makedirs(image_dir, exist_ok=True) - image_tiles = [image_tile.cpu().numpy() for image_tile in image_tiles] - for i in range(n_steps): - for j in range(n_envs): - image_pil = Image.fromarray(image_tiles[i][j]) - image_path = os.path.join(image_dir, f"step{i:02d}_env{j:02d}_{camera_mode}_{robot_uid}.png") - print(f"Image saved: {image_path}") - image_pil.save(image_path) - - env.close() - performance_results = { - "time_taken_gpu": profiler.get_total_rendering_gpu_time(), - "time_taken_cpu": profiler.get_total_rendering_cpu_time(), - "time_taken_per_env_gpu": profiler.get_total_rendering_gpu_time_per_env(), - "time_taken_per_env_cpu": profiler.get_total_rendering_cpu_time_per_env(), - "fps": profiler.get_rendering_fps(), - "fps_per_env": profiler.get_rendering_fps_per_env(), - } - write_benchmark_result_file(args, performance_results) - - -if __name__ == "__main__": - main() diff --git a/scripts/perf_benchmark/benchmark_omni.py b/scripts/perf_benchmark/benchmark_omni.py deleted file mode 100644 index 36b6a5a4..00000000 --- a/scripts/perf_benchmark/benchmark_omni.py +++ /dev/null @@ -1,330 +0,0 @@ -######################## Parse arguments ####################### -from batch_benchmark import BenchmarkArgs, write_benchmark_result_file -benchmark_args = BenchmarkArgs.parse_benchmark_args() -######################## Launch app ####################### -from isaaclab.app import AppLauncher -app = AppLauncher( - headless=not benchmark_args.gui, - enable_cameras=True, - device="cuda:0", -).app - -import os -import math -from scipy.spatial.transform import Rotation as R -import torch -from pxr import PhysxSchema -from PIL import Image - -import carb -import omni.replicator.core as rep -import isaaclab.sim as sim_utils -import isaacsim.core.utils.stage as stage_utils -import isaaclab.assets as asset_utils -import isaaclab_assets.robots as asset_robots -from isaaclab.scene.interactive_scene import InteractiveScene -from isaaclab.sensors import TiledCameraCfg -from isaaclab.utils.math import create_rotation_matrix_from_view, quat_from_matrix -from isaaclab.utils import configclass -from isaaclab.scene import InteractiveSceneCfg -from isaaclab.sim.converters import MjcfConverter, MjcfConverterCfg -from isaacsim.core.utils.extensions import enable_extension -enable_extension("isaacsim.asset.importer.mjcf") -import isaacsim.asset.importer.mjcf - -from benchmark_profiler import BenchmarkProfiler, get_utilization_percentages, print_system_utilization - - -# Get asset directory from environment variable -asset_dir = os.path.abspath(os.getenv("ASSET_DIR")) -benchmark_dir = os.path.abspath(os.path.dirname(__file__)) - - -def load_mjcf(mjcf_path: str) -> str: - return MjcfConverter( - MjcfConverterCfg( - asset_path=mjcf_path, - fix_base=True, - force_usd_conversion=True - ) - ).usd_path - - -def get_robot_config() -> asset_utils.AssetBaseCfg: - robot_name = f"{os.path.splitext(benchmark_args.mjcf)[0]}_new.xml" - robot_path = load_mjcf(os.path.abspath(os.path.join(asset_dir, robot_name))) - print("Robot asset:", robot_path) - - if benchmark_args.mjcf.endswith("g1.xml"): - robot_cfg = asset_utils.AssetBaseCfg( - spawn=asset_robots.unitree.G1_CFG.spawn.copy() - ) - elif benchmark_args.mjcf.endswith("go2.xml"): - robot_cfg = asset_utils.AssetBaseCfg( - spawn=asset_robots.unitree.UNITREE_GO2_CFG.spawn.copy() - ) - elif benchmark_args.mjcf.endswith("panda.xml"): - robot_cfg = asset_utils.AssetBaseCfg( - spawn=asset_robots.franka.FRANKA_PANDA_CFG.spawn.copy() - ) - else: - raise Exception(f"Invalid robot: {benchmark_args.mjcf}") - robot_cfg.spawn.usd_path = robot_path - return robot_cfg.replace(prim_path="{ENV_REGEX_NS}/Robot") - - -def get_dir_light_config() -> asset_utils.AssetBaseCfg: - dir_light_pos = torch.Tensor([[0.0, 0.0, 1.5]]) - dir_light_quat = quat_from_matrix( - create_rotation_matrix_from_view( - dir_light_pos, - torch.Tensor([[1.0, 1.0, -2.0]]), - stage_utils.get_stage_up_axis())) - dir_light_pos = tuple(dir_light_pos.detach().cpu().squeeze().numpy()) - dir_light_quat = tuple(dir_light_quat.detach().cpu().squeeze().numpy()) - dir_light_cfg = asset_utils.AssetBaseCfg( - prim_path="/World/direct_light", - spawn=sim_utils.DistantLightCfg(intensity=500.0, angle=45.0), - init_state=asset_utils.AssetBaseCfg.InitialStateCfg( - pos=dir_light_pos, rot=dir_light_quat - ) - ) - return dir_light_cfg - - -@configclass -class RobotSceneCfg(InteractiveSceneCfg): - """Configuration for a cart-pole scene.""" - ground = asset_utils.AssetBaseCfg( - # prim_path="{ENV_REGEX_NS}/ground", # Each environment should have a ground - prim_path="/World/ground", # All environment shares a ground - spawn=sim_utils.UsdFileCfg( - usd_path=os.path.abspath(os.path.join(benchmark_dir, "benchmark_assets/plane_usd/plane.usd")) - ), - ) - robot: asset_utils.ArticulationCfg = get_robot_config() - dir_light = get_dir_light_config() - - -def apply_benchmark_physics_settings(): - stage = stage_utils.get_current_stage() - physxSceneAPI = PhysxSchema.PhysxSceneAPI.Apply(stage.GetPrimAtPath("/physicsScene")) - physxSceneAPI.CreateGpuTempBufferCapacityAttr(16 * 1024 * 1024 * 2) - physxSceneAPI.CreateGpuHeapCapacityAttr(64 * 1024 * 1024 * 2) - physxSceneAPI.CreateGpuMaxRigidPatchCountAttr(8388608) - physxSceneAPI.CreateGpuMaxRigidContactCountAttr(16777216) - - -def print_render_settings(settings): - print("Render mode:", settings.get("/rtx/rendermode")) - print("Sample per pixel:", settings.get("/rtx/pathtracing/spp")) - print("Total spp:", settings.get("/rtx/pathtracing/totalSpp")) - print("Clamp spp:", settings.get("/rtx/pathtracing/clampSpp")) - print("Max bounce:", settings.get("/rtx/pathtracing/maxBounces")) - print("Optix Denoiser", settings.get("/rtx/pathtracing/optixDenoiser/enabled")) - print("Shadows", settings.get("/rtx/shadows/enabled")) - print("dlss/enabled:", settings.get("/rtx/post/dlss/enabled")) - print("dlss/auto:", settings.get("/rtx/post/dlss/auto")) - print("upscaling/enabled:", settings.get("/rtx/post/upscaling/enabled")) - print("aa/denoiser/enabled:", settings.get("/rtx/post/aa/denoiser/enabled")) - print("aa/taa/enabled:", settings.get("/rtx/post/aa/taa/enabled")) - print("motionBlur/enabled:", settings.get("/rtx/post/motionBlur/enabled")) - print("dof/enabled:", settings.get("/rtx/post/dof/enabled")) - print("bloom/enabled:", settings.get("/rtx/post/bloom/enabled")) - print("tonemap/enabled:", settings.get("/rtx/post/tonemap/enabled")) - print("exposure/enabled:", settings.get("/rtx/post/exposure/enabled")) - print("vsync:", settings.get("/app/window/vsync")) - - -def apply_benchmark_carb_settings(print_changes: bool = False) -> None: - # rep.settings.set_render_rtx_realtime() # Keep default pipeline; explicitly set below - settings = carb.settings.get_settings() - - # Print settings before applying the settings - if print_changes: - print("Before settings:") - print_render_settings(settings) - - # Options: https://docs.omniverse.nvidia.com/materials-and-rendering/latest/rtx-renderer_pt.html - if benchmark_args.rasterizer: - settings.set("/rtx/rendermode", "RayTracedLighting") - else: - settings.set("/rtx/rendermode", "PathTracing") - settings.set("/rtx/shadows/enabled", False) - - # Path tracing settings - settings.set("/rtx/pathtracing/spp", benchmark_args.spp) - settings.set("/rtx/pathtracing/totalSpp", benchmark_args.spp) - settings.set("/rtx/pathtracing/clampSpp", benchmark_args.spp) - settings.set("/rtx/pathtracing/maxBounces", benchmark_args.max_bounce) - settings.set("/rtx/pathtracing/optixDenoiser/enabled", False) - settings.set("/rtx/pathtracing/adaptiveSampling/enabled", False) - - # Disable DLSS & upscaling - settings.set("/rtx-transient/dlssg/enabled", False) - settings.set("/rtx/post/dlss/enabled", False) - settings.set("/rtx/post/dlss/auto", False) - settings.set("/rtx/post/upscaling/enabled", False) - - # Disable post-processing - settings.set("/rtx/post/aa/denoiser/enabled", False) - settings.set("/rtx/post/aa/taa/enabled", False) - settings.set("/rtx/post/motionBlur/enabled", False) - settings.set("/rtx/post/dof/enabled", False) - settings.set("/rtx/post/bloom/enabled", False) - settings.set("/rtx/post/tonemap/enabled", False) - settings.set("/rtx/post/exposure/enabled", False) - - # Disable VSync - settings.set("/app/window/vsync", False) - - # Print settings after applying the settings - if print_changes: - print("After settings:") - print_render_settings(settings) - - -def create_scene(): - """Create simulation and scene with camera and physics/render settings applied.""" - sim_cfg = sim_utils.SimulationCfg( - device="cuda:0", dt=0.01, use_fabric=False, - ) - sim = sim_utils.SimulationContext(sim_cfg) - scene_cfg = RobotSceneCfg(num_envs=benchmark_args.n_envs, env_spacing=10.0) - - apply_benchmark_physics_settings() - apply_benchmark_carb_settings(True) - - camera_fov = math.radians(benchmark_args.camera_fov) - camera_aperture = 20.955 - camera_fol = camera_aperture / (2 * math.tan(camera_fov / 2)) - - camera_pos = torch.tensor(( - benchmark_args.camera_posX, - benchmark_args.camera_posY, - benchmark_args.camera_posZ - )).reshape(-1, 3) - camera_lookat = torch.tensor(( - benchmark_args.camera_lookatX, - benchmark_args.camera_lookatY, - benchmark_args.camera_lookatZ - )).reshape(-1, 3) - camera_quat = quat_from_matrix( - create_rotation_matrix_from_view( - camera_lookat, camera_pos, stage_utils.get_stage_up_axis() - ) @ R.from_euler('z', 180, degrees=True).as_matrix() - ) - camera_pos = tuple(camera_pos.detach().cpu().squeeze().numpy()) - camera_quat = tuple(camera_quat.detach().cpu().squeeze().numpy()) - camera_cfg = TiledCameraCfg( - prim_path="{ENV_REGEX_NS}/tiled_camera", - update_period=0, - height=benchmark_args.resY, - width=benchmark_args.resX, - offset=TiledCameraCfg.OffsetCfg( - pos=camera_pos, - rot=camera_quat, - convention="ros" - ), - data_types=["rgb", "depth"], - spawn=sim_utils.PinholeCameraCfg( - focal_length=camera_fol, - horizontal_aperture=camera_aperture, - ), - ) - setattr(scene_cfg, "tiled_camera", camera_cfg) - scene = InteractiveScene(scene_cfg) - return sim, scene - - -def run_simulator( - sim: sim_utils.SimulationContext, - scene: InteractiveScene, -) -> None: - """Run the simulator with all cameras, and return timing analytics. Visualize if desired.""" - n_envs = benchmark_args.n_envs - n_steps = benchmark_args.n_steps - camera = scene["tiled_camera"] - camera_data_types = ["rgb", "depth"] - - # Initialize timing variables - system_utilization_analytics = get_utilization_percentages() - print_system_utilization(system_utilization_analytics) - - sim.reset() - dt = sim.get_physics_dt() - n_warm_steps = 3 - for i in range(n_warm_steps): - print(f"Warm up step {i}.") - sim.step() - camera.update(dt) - _ = camera.data - - print("Warm up finished.") - output_dir = os.path.dirname(benchmark_args.benchmark_result_file) - os.makedirs(output_dir, exist_ok=True) - image_dirname = f'{benchmark_args.renderer}-{benchmark_args.rasterizer}-{benchmark_args.n_envs}-{benchmark_args.resX}' - image_dir = os.path.join(output_dir, image_dirname) - - profiler = BenchmarkProfiler(n_steps, n_envs) - for i in range(n_steps): - print(f"Step {i}:") - get_utilization_percentages() - - # Measure the total simulation step time - profiler.on_simulation_start() - sim.step(render=False) - profiler.on_rendering_start() - sim.render() - - # Update cameras and process vision data within the simulation step - # Loop through all camera lists and their data_types - camera.update(dt=dt) - rgb_tiles = camera.data.output.get("rgb") - depth_tiles = camera.data.output.get("depth") - profiler.on_rendering_end() - - if n_steps < 10: - os.makedirs(image_dir, exist_ok=True) - rgb_tiles = rgb_tiles.detach().cpu().numpy() - for j in range(n_envs): - rgb_image = rgb_tiles[j] - rgb_image = Image.fromarray(rgb_image) - - image_name = f"rgb_step{i}_env{j}.png" - image_path = os.path.join(image_dir, image_name) - rgb_image.save(image_path) - print("Image saved:", image_path) - # End timing for the step - - profiler.end() - profiler.print_summary() - - system_utilization_analytics = get_utilization_percentages() - print_system_utilization(system_utilization_analytics) - - performance_results = { - "time_taken_gpu": profiler.get_total_rendering_gpu_time(), - "time_taken_cpu": profiler.get_total_rendering_cpu_time(), - "time_taken_per_env_gpu": profiler.get_total_rendering_gpu_time_per_env(), - "time_taken_per_env_cpu": profiler.get_total_rendering_cpu_time_per_env(), - "fps": profiler.get_rendering_fps(), - "fps_per_env": profiler.get_rendering_fps_per_env(), - } - write_benchmark_result_file(benchmark_args, performance_results) - - print("App closing..") - # app.close() - print("App closed!") - - -def main() -> None: - """Entry point for running the benchmark scene and simulator.""" - sim, scene = create_scene() - run_simulator(sim=sim, scene=scene) - - -if __name__ == "__main__": - # run the main function - main() - # simulation_app.close() diff --git a/scripts/perf_benchmark/benchmark_profiler.py b/scripts/perf_benchmark/benchmark_profiler.py deleted file mode 100644 index d288401d..00000000 --- a/scripts/perf_benchmark/benchmark_profiler.py +++ /dev/null @@ -1,270 +0,0 @@ -import time - -import numpy as np -import psutil -import torch -import pynvml - - -def get_utilization_percentages(reset: bool = False, max_values: list[float] = [0.0, 0.0, 0.0, 0.0]) -> list[float]: - """Get the maximum CPU, RAM, GPU utilization (processing), and - GPU memory usage percentages since the last time reset was true.""" - if reset: - max_values[:] = [0, 0, 0, 0] # Reset the max values - - # CPU utilization - cpu_usage = psutil.cpu_percent(interval=0.1) - max_values[0] = max(max_values[0], cpu_usage) - - # RAM utilization - memory_info = psutil.virtual_memory() - ram_usage = memory_info.percent - max_values[1] = max(max_values[1], ram_usage) - - # GPU utilization using pynvml - if torch.cuda.is_available(): - pynvml.nvmlInit() # Initialize NVML - for i in range(torch.cuda.device_count()): - handle = pynvml.nvmlDeviceGetHandleByIndex(i) - - # GPU Utilization - gpu_utilization = pynvml.nvmlDeviceGetUtilizationRates(handle) - gpu_processing_utilization_percent = gpu_utilization.gpu # GPU core utilization - max_values[2] = max(max_values[2], gpu_processing_utilization_percent) - - # GPU Memory Usage - memory_info = pynvml.nvmlDeviceGetMemoryInfo(handle) - gpu_memory_total = memory_info.total - gpu_memory_used = memory_info.used - gpu_memory_utilization_percent = (gpu_memory_used / gpu_memory_total) * 100 - max_values[3] = max(max_values[3], gpu_memory_utilization_percent) - - pynvml.nvmlShutdown() # Shutdown NVML after usage - return max_values - - -def print_system_utilization(analytics: list[float]) -> None: - print( - f"| CPU: {analytics[0]}% |" - f" RAM: {analytics[1]}% |" - f" GPU Compute: {analytics[2]}% |" - f" GPU Memory: {analytics[3]:.2f}% |" - ) - - -class BenchmarkProfiler: - def __init__(self, n_steps, n_envs): - self.reset(n_steps) - self.n_envs = n_envs - - def reset(self, n_steps): - self.n_steps = n_steps - # Create arrays of CUDA events for each step - # Each step has 3 events: simulation_start, render_start, render_end - self.events = [] - # CPU timing arrays - self.cpu_times = [] - for _ in range(n_steps): - step_events = { - "simulation_start": torch.cuda.Event(enable_timing=True), - "render_start": torch.cuda.Event(enable_timing=True), - "render_end": torch.cuda.Event(enable_timing=True), - } - self.events.append(step_events) - # Initialize CPU timing structure for each step - self.cpu_times.append({"simulation_start": 0.0, "render_start": 0.0, "render_end": 0.0}) - self.current_step = 0 - - # Synchronize all previous GPU events - torch.cuda.synchronize() - self.is_synchronized = False - - ######################## Profiling Events ####################### - def on_simulation_start(self): - """Record the start of simulation for current step""" - if self.current_step >= self.n_steps: - raise Exception("All steps have been profiled") - self.events[self.current_step]["simulation_start"].record() - self.cpu_times[self.current_step]["simulation_start"] = time.time() - - def on_rendering_start(self): - """Record the start of rendering for current step""" - if self.current_step >= self.n_steps: - raise Exception("All steps have been profiled") - self.events[self.current_step]["render_start"].record() - self.cpu_times[self.current_step]["render_start"] = time.time() - - def on_rendering_end(self): - """Record the end of rendering for current step""" - if self.current_step >= self.n_steps: - raise Exception("All steps have been profiled") - self.events[self.current_step]["render_end"].record() - self.cpu_times[self.current_step]["render_end"] = time.time() - self.current_step += 1 - - def end(self): - """End the profiler""" - self._synchronize() - - def _synchronize(self): - """Synchronize GPU to ensure all events are recorded""" - torch.cuda.synchronize() - self.is_synchronized = True - - ######################## Simulation Performance ####################### - def get_total_simulation_gpu_time(self): - """Calculate total simulation GPU time across all steps in seconds""" - if not self.is_synchronized: - raise Exception("GPU profiler is not synchronized") - total_time = 0.0 - for step in range(self.current_step): - events = self.events[step] - total_time += events["simulation_start"].elapsed_time(events["render_start"]) - return total_time / 1000.0 - - def get_total_simulation_cpu_time(self): - """Calculate total simulation CPU time across all steps in seconds""" - if not self.is_synchronized: - raise Exception("GPU profiler is not synchronized") - total_time = 0.0 - for step in range(self.current_step): - cpu_times = self.cpu_times[step] - total_time += (cpu_times["render_start"] - cpu_times["simulation_start"]) * 1000 # Convert to ms - return total_time / 1000.0 - - def get_simulation_fps(self): - """Get the FPS for the current step""" - if not self.is_synchronized: - raise Exception("GPU profiler is not synchronized") - return self.n_envs * self.n_steps / self.get_total_simulation_gpu_time() - - def get_simulation_fps_per_env(self): - """Get the FPS per env for the current step""" - if not self.is_synchronized: - raise Exception("GPU profiler is not synchronized") - return self.n_steps / self.get_total_simulation_gpu_time() - - ######################## Rendering Performance ####################### - def get_total_rendering_gpu_time(self): - """Calculate total rendering GPU time across all steps in seconds""" - if not self.is_synchronized: - raise Exception("GPU profiler is not synchronized") - total_time = 0.0 - for step in range(self.current_step): - events = self.events[step] - total_time += events["render_start"].elapsed_time(events["render_end"]) - return total_time / 1000.0 - - def get_total_rendering_cpu_time(self): - """Calculate total rendering CPU time across all steps in seconds""" - if not self.is_synchronized: - raise Exception("GPU profiler is not synchronized") - total_time = 0.0 - for step in range(self.current_step): - cpu_times = self.cpu_times[step] - total_time += (cpu_times["render_end"] - cpu_times["render_start"]) * 1000 # Convert to ms - return total_time / 1000.0 - - def get_rendering_fps(self): - """Get the FPS for the current step""" - if not self.is_synchronized: - raise Exception("GPU profiler is not synchronized") - return self.n_envs * self.n_steps / self.get_total_rendering_gpu_time() - - def get_rendering_fps_per_env(self): - """Get the FPS per env for the current step""" - if not self.is_synchronized: - raise Exception("GPU profiler is not synchronized") - return self.n_steps / self.get_total_rendering_gpu_time() - - def get_total_rendering_gpu_time_per_env(self): - """Get the total rendering GPU time per env for the current step in seconds""" - if not self.is_synchronized: - raise Exception("GPU profiler is not synchronized") - return self.get_total_rendering_gpu_time() / self.n_envs - - def get_total_rendering_cpu_time_per_env(self): - """Get the total rendering CPU time per env for the current step in seconds""" - if not self.is_synchronized: - raise Exception("GPU profiler is not synchronized") - return self.get_total_rendering_cpu_time() / self.n_envs - - ######################## Total Performance ####################### - def get_total_gpu_time(self): - """Calculate total GPU time across all steps in seconds""" - if not self.is_synchronized: - raise Exception("GPU profiler is not synchronized") - total_time = 0.0 - for step in range(self.current_step): - events = self.events[step] - total_time += events["simulation_start"].elapsed_time(events["render_end"]) - return total_time / 1000.0 - - def get_total_cpu_time(self): - """Calculate total CPU time across all steps in seconds""" - if not self.is_synchronized: - raise Exception("GPU profiler is not synchronized") - total_time = 0.0 - for step in range(self.current_step): - cpu_times = self.cpu_times[step] - total_time += (cpu_times["render_end"] - cpu_times["simulation_start"]) * 1000 # Convert to ms - return total_time / 1000.0 - - def get_total_gpu_time_per_env(self): - """Get the total GPU time per env for the current step in seconds""" - if not self.is_synchronized: - raise Exception("GPU profiler is not synchronized") - return self.get_total_gpu_time() / self.n_envs - - def get_total_cpu_time_per_env(self): - """Get the total CPU time per env for the current step in seconds""" - if not self.is_synchronized: - raise Exception("GPU profiler is not synchronized") - return self.get_total_cpu_time() / self.n_envs - - def get_step_times(self, step_idx): - """Get detailed timing for a specific step in seconds""" - if not self.is_synchronized: - raise Exception("GPU profiler is not synchronized") - if step_idx >= self.current_step: - raise Exception(f"Step {step_idx} has not been profiled yet") - - events = self.events[step_idx] - cpu_times = self.cpu_times[step_idx] - - return { - "simulation": { - "gpu_ms": events["simulation_start"].elapsed_time(events["render_start"]), - "cpu_ms": (cpu_times["render_start"] - cpu_times["simulation_start"]) * 1000, - }, - "rendering": { - "gpu_ms": events["render_start"].elapsed_time(events["render_end"]), - "cpu_ms": (cpu_times["render_end"] - cpu_times["render_start"]) * 1000, - }, - "total": { - "gpu_ms": events["simulation_start"].elapsed_time(events["render_end"]), - "cpu_ms": (cpu_times["render_end"] - cpu_times["simulation_start"]) * 1000, - }, - } - - ######################## Print Summary ####################### - def print_rendering_summary(self): - """Print a summary of the profiler""" - print(f"Total rendering GPU time: {self.get_total_rendering_gpu_time()} seconds") - print(f"Total rendering CPU time: {self.get_total_rendering_cpu_time()} seconds") - print(f"Total rendering GPU time per env: {self.get_total_rendering_gpu_time_per_env()} seconds") - print(f"Total rendering CPU time per env: {self.get_total_rendering_cpu_time_per_env()} seconds") - print(f"Rendering FPS: {self.get_rendering_fps()}") - print(f"Rendering FPS per env: {self.get_rendering_fps_per_env()}") - - def print_simulation_summary(self): - """Print a summary of the profiler""" - print(f"Total simulation GPU time: {self.get_total_simulation_gpu_time()} seconds") - print(f"Total simulation CPU time: {self.get_total_simulation_cpu_time()} seconds") - print(f"Simulation FPS: {self.get_simulation_fps()}") - print(f"Simulation FPS per env: {self.get_simulation_fps_per_env()}") - - def print_summary(self): - """Print a summary of the profiler""" - self.print_rendering_summary() - self.print_simulation_summary() diff --git a/scripts/perf_benchmark/benchmark_pyrender.py b/scripts/perf_benchmark/benchmark_pyrender.py deleted file mode 100644 index 3bed018b..00000000 --- a/scripts/perf_benchmark/benchmark_pyrender.py +++ /dev/null @@ -1,136 +0,0 @@ -import os - -import genesis as gs - -from batch_benchmark import BenchmarkArgs -from benchmark_profiler import BenchmarkProfiler - - -def init_gs(benchmark_args): - ########################## init ########################## - try: - gs.init(backend=gs.gpu) - except Exception as e: - print(f"Failed to initialize GPU backend: {e}") - print("Falling back to CPU backend") - gs.init(backend=gs.cpu) - - ########################## create a scene ########################## - scene = gs.Scene( - viewer_options=gs.options.ViewerOptions( - camera_pos=( - benchmark_args.camera_posX, - benchmark_args.camera_posY, - benchmark_args.camera_posZ, - ), - camera_lookat=( - benchmark_args.camera_lookatX, - benchmark_args.camera_lookatY, - benchmark_args.camera_lookatZ, - ), - camera_fov=benchmark_args.camera_fov, - ), - vis_options=gs.options.VisOptions( - lights=[ - { - "type": "directional", - "dir": (1.0, 1.0, -2.0), - "color": (1.0, 1.0, 1.0), - "intensity": 0.5, - }, - { - "type": "point", - "pos": (4, -4, 4), - "color": (1.0, 1.0, 1.0), - "intensity": 1, - }, - ], - ), - show_viewer=False, - rigid_options=gs.options.RigidOptions( - # constraint_solver=gs.constraint_solver.Newton, - ), - renderer=benchmark_args.rasterizer and gs.options.renderers.Rasterizer() or gs.options.renderers.RayTracer(), - ) - - ########################## entities ########################## - plane = scene.add_entity( - gs.morphs.Plane(), - ) - franka = scene.add_entity( - gs.morphs.MJCF(file=benchmark_args.mjcf), - visualize_contact=False, - ) - - ########################## cameras ########################## - cam_0 = scene.add_camera( - res=(benchmark_args.resX, benchmark_args.resY), - pos=( - benchmark_args.camera_posX, - benchmark_args.camera_posY, - benchmark_args.camera_posZ, - ), - lookat=( - benchmark_args.camera_lookatX, - benchmark_args.camera_lookatY, - benchmark_args.camera_lookatZ, - ), - fov=benchmark_args.camera_fov, - ) - ########################## build ########################## - scene.build() - return scene - - -def run_benchmark(scene, benchmark_args): - try: - n_envs = benchmark_args.n_envs - n_steps = benchmark_args.n_steps - - # warmup - scene.step() - rgb, depth, _, _ = scene.visualizer.cameras[0].render(rgb=True, depth=True) - - # Profiler - profiler = BenchmarkProfiler(n_steps, n_envs) - for i in range(n_steps): - profiler.on_simulation_start() - scene.step() - profiler.on_rendering_start() - rgb, depth, _, _ = scene.visualizer.cameras[0].render(rgb=True, depth=True) - profiler.on_rendering_end() - - profiler.end() - profiler.print_summary() - - time_taken_gpu = profiler.get_total_rendering_gpu_time() - time_taken_cpu = profiler.get_total_rendering_cpu_time() - time_taken_per_env_gpu = profiler.get_total_rendering_gpu_time_per_env() - time_taken_per_env_cpu = profiler.get_total_rendering_cpu_time_per_env() - fps = profiler.get_rendering_fps() - fps_per_env = profiler.get_rendering_fps_per_env() - - # Append a line with all args and results in csv format - os.makedirs(os.path.dirname(benchmark_args.benchmark_result_file), exist_ok=True) - with open(benchmark_args.benchmark_result_file, "a") as f: - f.write( - f"succeeded,{benchmark_args.mjcf},{benchmark_args.renderer},{benchmark_args.rasterizer},{benchmark_args.n_envs},{benchmark_args.n_steps},{benchmark_args.resX},{benchmark_args.resY},{benchmark_args.camera_posX},{benchmark_args.camera_posY},{benchmark_args.camera_posZ},{benchmark_args.camera_lookatX},{benchmark_args.camera_lookatY},{benchmark_args.camera_lookatZ},{benchmark_args.camera_fov},{time_taken_gpu},{time_taken_per_env_gpu},{time_taken_cpu},{time_taken_per_env_cpu},{fps},{fps_per_env}\n" - ) - except Exception as e: - print(f"Error during benchmark: {e}") - raise - - -def main(): - ######################## Parse arguments ####################### - benchmark_args = BenchmarkArgs.parse_benchmark_args() - - ######################## Initialize scene ####################### - scene = init_gs(benchmark_args) - - ######################## Run benchmark ####################### - run_benchmark(scene, benchmark_args) - - -if __name__ == "__main__": - main() diff --git a/scripts/perf_benchmark/benchmark_report_generator.py b/scripts/perf_benchmark/benchmark_report_generator.py deleted file mode 100644 index f285d9e6..00000000 --- a/scripts/perf_benchmark/benchmark_report_generator.py +++ /dev/null @@ -1,489 +0,0 @@ -import glob -import os -import html -import argparse - -import pandas as pd -import matplotlib.pyplot as plt -import numpy as np -from benchmark_configs import BenchmarkConfigs - - -def generate_table_html(plot_table_data): - # Add CSS styling for the table - html_table = """ - - \n""" - - # Get all batch sizes and renderers across all plots - all_batch_sizes = [] - all_renderers = [] - for renderer, renderer_data in plot_table_data.items(): - all_renderers.append(renderer) - for batch_size in renderer_data.keys(): - if batch_size not in all_batch_sizes: - all_batch_sizes.append(batch_size) - - sorted_batch_sizes = sorted(all_batch_sizes) - - # Header row with batch sizes - html_table += "" - for batch_size in sorted_batch_sizes: - html_table += f"" - html_table += "\n" - - # Data rows - renderer_data = [] - for renderer in all_renderers: - html_table += f"" - row_data = [] - for batch_size in sorted_batch_sizes: - if renderer not in plot_table_data or batch_size not in plot_table_data[renderer]: - row_data.append(None) - html_table += "" - else: - fps = plot_table_data[renderer][batch_size] - row_data.append(fps) - html_table += f"" - html_table += "\n" - renderer_data.append(row_data) - - # Add speedup row for every two renderers - if len(renderer_data) % 2 == 0: - html_table += f"" - last_renderer_data = [None, None] - for i in range(len(sorted_batch_sizes)): - if renderer_data[-2][i] is not None and renderer_data[-1][i] is not None: - ratio = renderer_data[-1][i] / renderer_data[-2][i] - last_renderer_data[-2] = renderer_data[-2][i] - last_renderer_data[-1] = renderer_data[-1][i] - html_table += f"" - elif renderer_data[-2][i] is not None and renderer_data[-1][i] is None: - ratio = last_renderer_data[-1] / renderer_data[-2][i] - last_renderer_data[-2] = renderer_data[-2][i] - html_table += f"" - elif renderer_data[-2][i] is None and renderer_data[-1][i] is not None: - ratio = renderer_data[-1][i] / last_renderer_data[-2] - last_renderer_data[-1] = renderer_data[-1][i] - html_table += f"" - else: - html_table += "" - html_table += "\n" - - html_table += "
Renderer{batch_size}
{html.escape(renderer)}N/A{fps:.1f}
Speedup{ratio:.1f}x{ratio:.1f}x{ratio:.1f}xN/A
" - return html_table - - -def generatePlotHtml(plots_dir, all_plot_table_data): - # Generate an html page to display all the plots - - # Get all plot files - plot_files = glob.glob(f"{plots_dir}/*.png") - if len(plot_files) == 0: - print(f"No plot files found in {plots_dir}") - return - - # Separate regular plots from comparison charts - regular_plot_files = [p for p in plot_files if p.endswith("_plot.png") and not p.endswith("_comparison_plot.png")] - - # Group regular plots by MJCF file - plot_groups = {} - for plot_file in regular_plot_files: - basename = os.path.basename(plot_file) - mjcf_name = basename.split("_")[0] - if mjcf_name not in plot_groups: - plot_groups[mjcf_name] = [] - plot_groups[mjcf_name].append(plot_file) - - # Sort plot groups by mjcf name and plot file name - plot_groups = sorted(plot_groups.items(), key=lambda x: (x[0], x[1][0])) - - # Group comparison plots by resolution - comparison_plot_files = {} - for plot_file in plot_files: - if plot_file.endswith("_comparison_plot.png"): - # Extract resolution from filename (e.g., "128x128" from "..._128x128_comparison_plot.png") - resolution = plot_file.split("_")[-3] # Get the resolution part - if resolution not in comparison_plot_files: - comparison_plot_files[resolution] = [] - comparison_plot_files[resolution].append(plot_file) - - # Sort resolutions by their dimensions - def get_resolution_dims(res): - width, height = map(int, res.split("x")) - return width * height # Sort by total pixels - - sorted_resolutions = sorted(comparison_plot_files.keys(), key=get_resolution_dims) - - # Create HTML file - html_content = """ - - - - Benchmark Results - - - -

Benchmark Results

- """ - - # Add comparison plots sections by resolution - if comparison_plot_files: - html_content += "
\n" - html_content += "

Performance Comparison Plots

\n" - for resolution in sorted_resolutions: - html_content += f"

Resolution: {resolution}

\n" - html_content += "
\n" - for plot in comparison_plot_files[resolution]: - html_content += generate_table_html(all_plot_table_data[plot]) - html_content += f"{html.escape(os.path.basename(plot))}
\n" - html_content += "
\n" - html_content += "
\n" - - # Add regular plots section - html_content += "
\n" - html_content += "

Performance Plots

\n" - for mjcf_name, plots in plot_groups: - html_content += f"
\n" - for plot in plots: - html_content += f"

{html.escape(mjcf_name)} - {os.path.basename(plot)}

\n" - html_content += ( - f"{html.escape(os.path.basename(plot))}
\n" - ) - html_content += "
\n" - html_content += "
\n" - - html_content += """ - - - """ - - # Write HTML file - with open(f"{plots_dir}/index.html", "w") as f: - f.write(html_content) - - -def get_comparison_data_list(config_file): - config = BenchmarkConfigs(config_file) - return config.comparison_list - - -def generate_report(data_file_path, config_file, width=20, height=15): - # Load the log file as csv - # For each mjcf, rasterizer (rasterizer or not(=raytracer)), generate a plot image and save it to a directory. - # The plot image has batch size on the x-axis and fps on the y-axis. - # Each resolution has a different color. - # The plot image has a legend for the resolution. - # The plot image has a title for the mjcf. - # The plot image has a x-axis label for the batch size. - # The plot image has a y-axis label for the fps. - - # Read CSV file - df = pd.read_csv(data_file_path) - - # Create plots directory if it doesn't exist - plots_dir = os.path.dirname(data_file_path) - if not os.path.exists(plots_dir): - os.makedirs(plots_dir) - # Generate individual plots for each mjcf/rasterizer combination - generate_individual_plots(df, plots_dir, width, height) - - # Generate difference plots for specific aspect ratios - all_plot_table_data = dict() - for aspect_ratio in ["1:1", "4:3", "16:9"]: - for comparison_list in get_comparison_data_list(config_file): - plot_table_data = generate_comparison_plots(df, plots_dir, width, height, comparison_list, aspect_ratio) - all_plot_table_data.update(plot_table_data) - - # Generate an html page to display all the plots - generatePlotHtml(plots_dir, all_plot_table_data) - - -def generate_individual_plots(df, plots_dir, width, height): - # Get unique combinations of mjcf and rasterizer - for mjcf in df["mjcf"].unique(): - for renderer in df[df["mjcf"] == mjcf]["renderer"].unique(): - for rasterizer in df[(df["mjcf"] == mjcf) & (df["renderer"] == renderer)]["rasterizer"].unique(): - # Filter data for this mjcf and rasterizer - data = df[(df["mjcf"] == mjcf) & (df["renderer"] == renderer) & (df["rasterizer"] == rasterizer)] - - # continue if there is no data - if len(data) == 0: - print(f"No data found for {mjcf} and {renderer} and rasterizer:{rasterizer}") - continue - - # Create new figure - plt.figure(figsize=(width, height)) - - # Group data by resolution - resolutions = sorted(data.groupby(["resX", "resY"]), key=lambda x: (x[0][0], x[0][1])) - - # Get all batch sizes - all_batch_sizes = sorted(data["n_envs"].unique()) - - # Create bar chart - x = np.arange(len(all_batch_sizes)) - bar_width = 0.8 / len(resolutions) - - # Plot bars for each resolution - for i, (resolution, res_data) in enumerate(resolutions): - # Create mapping from batch size to index - batch_to_idx = {batch: idx for idx, batch in enumerate(all_batch_sizes)} - - # Create array of FPS for all batch sizes - fps_array = np.zeros(len(all_batch_sizes)) - for batch, fps in zip(res_data["n_envs"], res_data["fps"]): - fps_array[batch_to_idx[batch]] = fps - - # Plot bars - bars = plt.bar( - x + i * bar_width, - fps_array, - bar_width, - label=f"{resolution[0]}x{resolution[1]}", - ) - - # Add value labels on top of bars - for bar in bars: - bar_height = bar.get_height() - if bar_height > 0: # Only add label if there's a value - plt.annotate( - f"{bar_height:.1f}", - xy=(bar.get_x() + bar.get_width() / 2, bar_height), - xytext=(0, 3), # 3 points vertical offset - textcoords="offset points", - ha="center", - va="bottom", - fontsize=8, - ) - - # Customize plot - plt.title( - f'Performance for {os.path.basename(mjcf)}\n{renderer} {"Rasterizer" if rasterizer else "Raytracer"}' - ) - plt.xlabel("Batch Size") - plt.ylabel("FPS") - plt.xticks(x + bar_width * (len(resolutions) - 1) / 2, all_batch_sizes) - plt.legend(title="Resolution") - plt.grid(True, axis="y") - - # Save plot - plot_filename = f"{plots_dir}/{os.path.splitext(os.path.basename(mjcf))[0]}_{renderer}_{'rasterizer' if rasterizer else 'raytracer'}_plot.png" - plt.savefig(plot_filename) - plt.close() - - -def generate_comparison_plots(df, plots_dir, width, height, comparison_list, aspect_ratio=None): - renderer_array = [comparison_info["renderer"] for comparison_info in comparison_list] - renderer_is_rasterizer_array = [comparison_info["rasterizer"] for comparison_info in comparison_list] - rasterizer_str_array = [ - "rasterizer" if renderer_is_rasterizer else "raytracer" - for renderer_is_rasterizer in renderer_is_rasterizer_array - ] - - # Filter by aspect ratio if specified - if aspect_ratio: - if aspect_ratio == "1:1": - df = df[df["resX"] == df["resY"]] - elif aspect_ratio == "4:3": - df = df[df["resX"] * 3 == df["resY"] * 4] - elif aspect_ratio == "16:9": - df = df[df["resX"] * 9 == df["resY"] * 16] - else: - raise ValueError(f"Unsupported aspect ratio: {aspect_ratio}") - - plot_table_data = dict() - - plt.clf() - plt.cla() - - # Generate plots showing fps comparison between renderer_1 and renderer_2 - for mjcf in df["mjcf"].unique(): - mjcf_data = df[df["mjcf"] == mjcf] - - # Get resolutions available for both renderer_1 and renderer_2 - for comparison in comparison_list: - renderer = comparison["renderer"] - renderer_is_rasterizer = comparison["rasterizer"] - renderer_resolutions = [ - set( - zip( - mjcf_data[ - (mjcf_data["renderer"] == renderer) & (mjcf_data["rasterizer"] == renderer_is_rasterizer) - ]["resX"], - mjcf_data[ - (mjcf_data["renderer"] == renderer) & (mjcf_data["rasterizer"] == renderer_is_rasterizer) - ]["resY"], - ) - ) - ] - print(f"renderer: {renderer}, renderer_is_rasterizer: {renderer_is_rasterizer}") - print(f"renderer_resolutions: {renderer_resolutions}") - common_res = set.intersection(*renderer_resolutions) - - # continue if there is no data - if len(common_res) == 0: - print(f"No data found for {mjcf}") - continue - - # Plot comparison for each resolution - for resX, resY in sorted(common_res, key=lambda x: x[0] * x[1]): - plt.figure(figsize=(width, height)) - renderer_data_array = [] - for comparison in comparison_list: - renderer = comparison["renderer"] - renderer_is_rasterizer = comparison["rasterizer"] - renderer_data = mjcf_data[ - (mjcf_data["result"] == "succeeded") - & (mjcf_data["renderer"] == renderer) - & (mjcf_data["rasterizer"] == renderer_is_rasterizer) - & (mjcf_data["resX"] == resX) - & (mjcf_data["resY"] == resY) - ] - renderer_data_array.append(renderer_data) - - # Match batch sizes and calculate difference - batch_sizes = set.union(*[set(renderer_data["n_envs"]) for renderer_data in renderer_data_array]) - sorted_batch_sizes = sorted(list(batch_sizes)) - - # Create bar chart - def add_labels(bars): - for bar in bars: - bar_height = bar.get_height() - plt.annotate( - f"{bar_height:.1f}", - xy=(bar.get_x() + bar.get_width() / 2, bar_height), - xytext=(0, 3), # 3 points vertical offset - textcoords="offset points", - ha="center", - va="bottom", - fontsize=8, - ) - - # Plot bars - bar_width = 0.8 / len(comparison_list) - fps_array = [ - renderer_data[renderer_data["n_envs"].isin(sorted_batch_sizes)]["fps"].values - for renderer_data in renderer_data_array - ] - for i, (fps, renderer, rasterizer_str) in enumerate(zip(fps_array, renderer_array, rasterizer_str_array)): - x = np.arange(len(fps)) - bars = plt.bar( - x + i * bar_width, - fps, - bar_width, - label=f"{renderer} {rasterizer_str}", - ) - add_labels(bars) - - # Customize plot - renderer_str_array = [ - f"{renderer} {rasterizer_str}" for renderer, rasterizer_str in zip(renderer_array, rasterizer_str_array) - ] - renderer_str_array_str = ", ".join(renderer_str_array) - plt.title(f"FPS Comparison: {renderer_str_array_str}\n{os.path.basename(mjcf)} - Resolution: {resX}x{resY}") - plt.xlabel("Batch Size") - plt.ylabel("FPS") - plt.xticks(np.arange(len(sorted_batch_sizes)), sorted_batch_sizes) - plt.legend() - plt.grid(True, axis="y") - - # Save plot - renderer_str_array_str_for_filename = renderer_str_array_str.replace(",", "_") - plot_filename = f"{plots_dir}/{os.path.splitext(os.path.basename(mjcf))[0]}_{renderer_str_array_str_for_filename}_{resX}x{resY}_comparison_plot.png" - plt.savefig(plot_filename, dpi=100) # Added dpi parameter for better quality - plt.close() - - # Create a table of the data in plot_table_data, the key is the plot_filename, the value is a nested dict - # The key of the outer dict is "{renderer} - {rasterizer_str}" - # The key of the inner dict is "batch_size" - # The value of the inner dict is the fps - plot_table_data[plot_filename] = { - f"{renderer} - {rasterizer_str}": { - batch_size: fps for batch_size, fps in zip(sorted_batch_sizes, fps_array[i]) - } - for i, (renderer, rasterizer_str) in enumerate(zip(renderer_array, rasterizer_str_array)) - } - - return plot_table_data - - -def main(): - import sys - import os - - print("Script arguments:", sys.argv) # Debug print - - parser = argparse.ArgumentParser() - parser.add_argument( - "-d", - "--data_file_path", - type=str, - default="scripts/perf_benchmark/benchmark_reports/Perf_Run_Name/perf_data.csv", - help="Path to the benchmark data CSV file", - ) - parser.add_argument( - "-c", - "--config_file", - type=str, - default="benchmark_config_smoke_test.yml", - help="Path to the benchmark config file", - ) - parser.add_argument("-w", "--width", type=int, default=20, help="Width of the plot in inches") - parser.add_argument("-y", "--height", type=int, default=8, help="Height of the plot in inches") - - # If no arguments provided, try to get from environment variables - if len(sys.argv) == 1: - data_file = os.environ.get("BENCHMARK_DATA_FILE") - if data_file: - sys.argv.extend(["-d", data_file]) - - args = parser.parse_args() - print("Parsed arguments:", args) # Debug print - generate_report(args.data_file_path, args.config_file, args.width, args.height) - - -if __name__ == "__main__": - main() diff --git a/scripts/perf_benchmark/configs/benchmark_config_full.yml b/scripts/perf_benchmark/configs/benchmark_config_full.yml deleted file mode 100644 index 0df95f9f..00000000 --- a/scripts/perf_benchmark/configs/benchmark_config_full.yml +++ /dev/null @@ -1,100 +0,0 @@ -mjcf_list: - - xml/franka_emika_panda/panda.xml - - xml/unitree_g1/g1.xml - - xml/unitree_go2/go2.xml - -renderer_list: - - renderer: madrona - benchmark_script: benchmark_madrona.py - timeout: 120 - -rasterizer_list: - - true - - false - -batch_size_list: - - 1 - - 2 - - 4 - - 8 - - 16 - - 32 - - 64 - - 128 - - 256 - - 512 - - 768 - - 1024 - - 1536 - - 2048 - - 3072 - - 4096 - - 6144 - - 8192 - - 12288 - - 16384 - -resolution_list: - #square: - - [64, 64] - - [128, 128] - - [256, 256] - - [512, 512] - - [1024, 1024] - - [2048, 2048] - - [4096, 4096] - - [8192, 8192] - - #four_three: - - [320, 240] - - [640, 480] - - [800, 600] - - [1024, 768] - - [1280, 960] - - [1600, 1200] - - [1920, 1440] - - [2048, 1536] - - [2560, 1920] - - [3200, 2400] - - [4096, 3072] - - [8192, 6144] - - #sixteen_nine: - - [320, 180] - - [640, 360] - - [800, 450] - - [1024, 576] - - [1280, 720] - - [1600, 900] - - [1920, 1080] - - [2048, 1152] - - [2560, 1440] - - [3200, 1800] - - [4096, 2304] - - [8192, 4608] - -comparison_list: - - - renderer: madrona - rasterizer: true - - renderer: madrona - rasterizer: false - -# Configurations shared betwen batch_benchmark.py and benchmark_*.py -# Raytracer configuration -raytracer: - max_bounce: 2 - spp: 1 - -# Simulation configuration -simulation: - n_steps: 1000 - -# Camera configuration -camera: - position: [1.5, 0.5, 1.5] # [x, y, z] - lookat: [0.0, 0.0, 0.5] # [x, y, z] - fov: 45.0 # degrees - -# Display configuration -display: - gui: false # Enable/disable GUI mode diff --git a/scripts/perf_benchmark/configs/benchmark_config_madrona.yml b/scripts/perf_benchmark/configs/benchmark_config_madrona.yml deleted file mode 100644 index b9782c05..00000000 --- a/scripts/perf_benchmark/configs/benchmark_config_madrona.yml +++ /dev/null @@ -1,68 +0,0 @@ -mjcf_list: - - xml/franka_emika_panda/panda.xml - -renderer_list: - - renderer: madrona - benchmark_script: benchmark_madrona.py - timeout: 120 - -rasterizer_list: - - true - - false - -batch_size_list: - - 1 - - 2 - - 4 - - 8 - - 16 - - 32 - - 64 - - 128 - - 256 - - 512 - - 768 - - 1024 - - 1536 - - 2048 - - 3072 - - 4096 - - 6144 - - 8192 - - 12288 - - 16384 - -resolution_list: - #square: - - [128, 128] - - [256, 256] - - #four_three: - - #sixteen_nine: - -comparison_list: - - - renderer: madrona - rasterizer: true - - renderer: madrona - rasterizer: false - -# Configurations shared betwen batch_benchmark.py and benchmark_*.py -# Raytracer configuration -raytracer: - max_bounce: 2 - spp: 1 - -# Simulation configuration -simulation: - n_steps: 100 - -# Camera configuration -camera: - position: [1.5, 0.5, 1.5] # [x, y, z] - lookat: [0.0, 0.0, 0.5] # [x, y, z] - fov: 45.0 # degrees - -# Display configuration -display: - gui: false # Enable/disable GUI mode diff --git a/scripts/perf_benchmark/configs/benchmark_config_maniskill.yml b/scripts/perf_benchmark/configs/benchmark_config_maniskill.yml deleted file mode 100644 index 1c37780f..00000000 --- a/scripts/perf_benchmark/configs/benchmark_config_maniskill.yml +++ /dev/null @@ -1,58 +0,0 @@ -mjcf_list: - - xml/franka_emika_panda/panda.xml - -renderer_list: - - renderer: maniskill - benchmark_script: benchmark_maniskill.py - timeout: 120 - -rasterizer_list: - - true - -batch_size_list: - - 1 - - 2 - - 4 - - 8 - - 16 - - 32 - - 64 - - 128 - - 256 - - 512 - - 768 - - 1024 - - 2048 - -resolution_list: - #square: - - [128, 128] - - [256, 256] - - #four_three: - - #sixteen_nine: - -comparison_list: - - - renderer: maniskill - rasterizer: true - -# Configurations shared betwen batch_benchmark.py and benchmark_*.py -# Raytracer configuration -raytracer: - max_bounce: 2 - spp: 1 - -# Simulation configuration -simulation: - n_steps: 100 - -# Camera configuration -camera: - position: [1.5, 0.5, 1.5] # [x, y, z] - lookat: [0.0, 0.0, 0.5] # [x, y, z] - fov: 45.0 # degrees - -# Display configuration -display: - gui: false # Enable/disable GUI mode diff --git a/scripts/perf_benchmark/configs/benchmark_config_omni.yml b/scripts/perf_benchmark/configs/benchmark_config_omni.yml deleted file mode 100644 index dd0386e0..00000000 --- a/scripts/perf_benchmark/configs/benchmark_config_omni.yml +++ /dev/null @@ -1,61 +0,0 @@ -mjcf_list: - - xml/franka_emika_panda/panda.xml - -renderer_list: - - renderer: omniverse - benchmark_script: benchmark_omni.py - timeout: 120 - -rasterizer_list: - - true - - false - -batch_size_list: - - 1 - - 2 - - 4 - - 8 - - 16 - - 32 - - 64 - - 128 - - 256 - - 512 - - 768 - - 1024 - - 2048 - -resolution_list: - #square: - - [128, 128] - - [256, 256] - - #four_three: - - #sixteen_nine: - -comparison_list: - - - renderer: omniverse - rasterizer: true - - renderer: omniverse - rasterizer: false - -# Configurations shared betwen batch_benchmark.py and benchmark_*.py -# Raytracer configuration -raytracer: - max_bounce: 2 - spp: 1 - -# Simulation configuration -simulation: - n_steps: 100 - -# Camera configuration -camera: - position: [1.5, 0.5, 1.5] # [x, y, z] - lookat: [0.0, 0.0, 0.5] # [x, y, z] - fov: 45.0 # degrees - -# Display configuration -display: - gui: false # Enable/disable GUI mode diff --git a/scripts/perf_benchmark/configs/benchmark_config_smoke_test.yml b/scripts/perf_benchmark/configs/benchmark_config_smoke_test.yml deleted file mode 100644 index da28d074..00000000 --- a/scripts/perf_benchmark/configs/benchmark_config_smoke_test.yml +++ /dev/null @@ -1,49 +0,0 @@ -mjcf_list: - - xml/franka_emika_panda/panda.xml - -renderer_list: - - renderer: madrona - benchmark_script: benchmark_madrona.py - timeout: 120 - -rasterizer_list: - - true - - false - -batch_size_list: - - 256 - -resolution_list: - #square: - - [128, 128] - - [256, 256] - - #four_three: - - #sixteen_nine: - -comparison_list: - - - renderer: madrona - rasterizer: true - - renderer: madrona - rasterizer: false - -# Configurations shared betwen batch_benchmark.py and benchmark_*.py -# Raytracer configuration -raytracer: - max_bounce: 2 - spp: 1 - -# Simulation configuration -simulation: - n_steps: 1000 - -# Camera configuration -camera: - position: [1.5, 0.5, 1.5] # [x, y, z] - lookat: [0.0, 0.0, 0.5] # [x, y, z] - fov: 45.0 # degrees - -# Display configuration -display: - gui: false # Enable/disable GUI mode diff --git a/scripts/perf_benchmark/example_report/index.html b/scripts/perf_benchmark/example_report/index.html deleted file mode 100644 index 2cc6b936..00000000 --- a/scripts/perf_benchmark/example_report/index.html +++ /dev/null @@ -1,123 +0,0 @@ - - - - - Benchmark Results - - - -

Benchmark Results

-
-

Performance Comparison Plots

-

Resolution: 128x128

-
- - - - - - - -
Renderer12481632641282565127681024153620483072
madrona - rasterizer550.01077.72099.94002.87210.412448.219639.127706.233683.333811.828812.228596.128518.228085.528065.9
madrona - raytracer526.11025.61980.43860.07022.613338.621824.832294.740519.249755.435023.335532.336919.237179.037510.0
Speedup1.0x1.0x0.9x1.0x1.0x1.1x1.1x1.2x1.2x1.5x1.2x1.2x1.3x1.3x1.3x
panda_madrona rasterizer_ madrona raytracer_128x128_comparison_plot.png
-
-

Resolution: 256x256

-
- - - - - - - -
Renderer12481632641282565127681024153620483072
madrona - rasterizer547.61066.12031.83771.66762.310946.516284.721487.813058.213682.113859.313990.213842.013674.613512.6
madrona - raytracer480.0939.41748.33265.65704.68972.412211.415166.110396.810825.110909.511107.511148.211062.111146.5
Speedup0.9x0.9x0.9x0.9x0.8x0.8x0.7x0.7x0.8x0.8x0.8x0.8x0.8x0.8x0.8x
panda_madrona rasterizer_ madrona raytracer_256x256_comparison_plot.png
-
-
-
-

Performance Plots

-
-

panda - panda_madrona_rasterizer_plot.png

-panda_madrona_rasterizer_plot.png
-

panda - panda_madrona_raytracer_plot.png

-panda_madrona_raytracer_plot.png
-
-
- - - - \ No newline at end of file diff --git a/scripts/perf_benchmark/example_report/panda_madrona rasterizer_ madrona raytracer_128x128_comparison_plot.png b/scripts/perf_benchmark/example_report/panda_madrona rasterizer_ madrona raytracer_128x128_comparison_plot.png deleted file mode 100644 index 9d3e9e774fed6c462b66b56092441478b14e4332..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 74700 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 Qty=0KuZF*pYl;8(FFJJf_5c6? diff --git a/scripts/perf_benchmark/example_report/panda_madrona rasterizer_ madrona raytracer_128x128_comparison_table.png b/scripts/perf_benchmark/example_report/panda_madrona rasterizer_ madrona raytracer_128x128_comparison_table.png deleted file mode 100644 index eb00e7b8054901bb75500efcd51cbe8bb239a07a..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 47610 zcmeFYXH?VOw>63l6jTH$(p8%D-b+wXq$^18U3#yfsE8oFcL+$Ykxr-rN~j4vbfkuk z5J(6i;mz~E?>OT;cbrf6%l&fp7%6*<{i|#3wdR_8?r(3kRPK>8l9P~-+*5s{tV2RV zmP7pCe4CW`jr_2SB>r6U)=_yuQVV0=A$D#$JlA|qLQkt5UJ zKG)Lt?lX~)KoV4ypT7?<-(Ly~hMlcl9Yc{!%_-jVxs969X_JY<~Ct=0?y zM1*?eV-{Sc{LB!zPRv;-o^Tx6NkAra;;sP7$}Z-nZBc+Y&^Dp|J;~DEuC~?tPM+XO zsR-?}GT}B+9#gP9`6ORT@7YkXN9X_4y@KLzQT?kW)qnV5r2MaTy|l;vUt_55WRm8* z`qvl^OGX#Ee+^3d==i@U`v35b(15W0rK>AQ_cH>}dd_~$-6L#zREQq()>L`9NwP=LSmTn~PDWKQ23dxMWegynE)*(@XY&k=!F)fl_{u8Uug- zGGnvT&j8HA{`&L@q4{E})(&{k`zrb*p}}EKb|yIU$nWRoX7p`#`kKsoYhIQ;hHhFG zU|eMW+zz2|xIgWIq9>k9q}W-^?JiierYaU8NJINq^TLl#Xl|GXb3>3l!{J<6*mn6k z>947qe{wKn!cGvJLoTB)+wUkh|FOn=fy+?Hn{mi6_f@-m-+*4x;ddu)F~MHEZ_4-e zY6khp12>^6ml|dPZXr&rAn4k!B;SOj^LX5zAEE5Hyqzp%4E#{wz>54e+gt{jV2q+@ zWk_qh^|VP|>p0#{NIpVuY6%}$?Z9pAPF~h55E~sjfKKXX-xk^RJnXI#kuqN&p%P29)E54%1DQrjiA6;`?UFf^b4{xRn0yF-eU=ZaS8?wv zchm!gw(c_5dZuKE4yPYo?8elk160O5XN6f019H@}`P(mJOQD%Pi!1%9>G-IZX*F#T zRcYcI75t}p@Sq1$@lCo5RKB$4Yu7UL2&Of?(>B23{l>Svg6D7iL*v-z)111429AcS z6ATm;=1duCD^+XeR~G|-l%*qiT5KlV-BCAR^5q9qM!FUl@#-x;(-&Y>0WE0W5>cwj zp6V*34dLQ!a8c%)WYU>l%YU@>=tFuX-E&Zx{>f>d|Jb&%gUyWs{R0n1UuM{6yP_yD zwx_g$Zu{>qW@R-)#CqL(qO=-2dT2ro=1#h~^En|c;QQC_d~L0xFckit=ginv$<+jo zD_xJ8t7hCQysI;(hyQ)gX71AS?gwYa52Qbkaq2@brlKGKtPlOkzJfXwS*AZ>yC|GK z%BGdDwVd#dm6_u#D6zv$&_?#PoLsZVsI!#fbl;-~p5xKw&Px`2F+(#$GzQ>c%*2W~ ztCZh0KJK%}Fy>OFnz4DJsV^b1Jbbzt2!rX9*YOvQ+v-(3>TCdv}*#a2lpqi-U24`C$m?~&q2j${N7+d&O(86 zX^=Ebvy)Z+xFDp!=#pw0D61QM|FPVye4<^scZ^C;UG#W0FRJOM_j;-&8W_C6CdSRs zLZrg)!YjX-iMQ{McK8Fn`xtF2fX<3(b-&J$Wp}3cx zjrFIEe{6M=os|#{m%iTQI2Xo$W6>g=aKv zB5BIOICudt1||IO{3-2wP~nb&aX7h>CT6irt$V?z5oX>@FS#iF7=oi1CBHrC67zL3 zTZoQ)7}VPBLj4@{h3vzQ9C_5sn=FwE!=HNyGnxV$aD0C{Rc(_a_Akfl*1PEpcEO8H zifeDrT~ArY2l3NaPDDFV<_G+d+zqIq4|Hlt--7qC4}oeJgll6jY9lXG(~BN?wt!{h z%X}PO$?UG{DTR;roE}^|1X_MvfQzT^%fhf0xolOmlV7Q})|EMgyZ3%2R!{`k9D2-8 zquz-mM^A!N)SnxqHbru#ka?_R&Q)WjK@@1I+DbUf(t-@&ncgoDL!wSr&?3uA7N|Qt zmdlrRo%PYr*3HQC{-lqOD?K}8S9srxABS_-?KVMT9hp{(3N2^8eYrmlgVWhHIAFzz z_rkcBKq~{F3uQ8!j;a!7X_xYrL-1xO*TX}UAkUI*GMRtia%(=kA zU%Ed%v_gWdpAQoj?b;CZu#nzthyH~N>3WSoS65>{=Kb?~1)Bm?Fs(E1$VcXtAX^d) zgrB0SzDcJk_Q?d561F)0_<lB?|1vaJIAx@8~b|rq5&RAciVg z*|0RF8teBJe;e=|EFL@nq07HAz~;HI7)uR|K1{gntc5lT_asqX3f3GyoZ)J zX!Zo}E9N?ubz`zB)uI)Q|0P$~D~Y3?hj1ag&0<#ZGnoQQ#5YCH(&zV8j9Oz4j9_oe zeJ5G&dcM!h7U6)=_{CqKNcsg<`V0WF_lwXq2c2jW=d(v)ug2;z&*-X+f2QV(D3UTR z84iF{8skKMNAUa255YONd`$VW)0dbdm5^3GqG`vk$|xWC{unfowvMLobvq~?1l}k5 z*i;m{^bSX97f1q>xOcn2!`Kgf?C~8$neZ&E*;@EOgRe7u5gR;Q}RHM8u& zFN}*_w(wpDLa4*0k{uDfC8&#q*yaL_o3BRfwc{(yeF{IGVpc>67V#GWdSjN}5ySTI zY#A5a#FSE7rTxyF#@W1xb^rC{+4?NZay{N9FslikPy{_%1l$eD51GS4?27*Qn`McH zm7vb0r2h6-IxtL*s=wYKV3rquG7XoC_9nzuO|1kui^-Xr3|2CPD~$YL_|1#+?K%@3 z>;wusPm@AD-K(=;QIBvA?Ay{VyjAADUG7(`x@{`$`E&(HpJk6+;5F@E#7t1NbtDGk ze3;0m0(uh8FxK2<^R0}6UW1|jeKc!JHlyTkqYgP=79inHF7ERI3Vo8sGh9WxyKkLX zxeCAgrouF?R$}_*3BciZ@{Qc)R$XC58#iV(hDk$f9sTQ zS*P$CL=NmRTuz#jPJ5L~q?t{d-Nc(@?_S=58UcV5DzN8=k;4J_H=pWO43A$$hxV*5 zrxL^C3%V0sb;o+7@8!(#?yIhOg~&7L$?76~wf3O!!E=KY3$l*bhSm@Cu0fyKL8p2Y zbD{hzGtj!+Mt4n6%}(~A?DC&|gJ(m4cj)Dhye51G?H8~gcoC3L=l;v>r+>tk)6&m0 z^NNgsfnIU%Rz?+?Q;>cjkDc?{O=wCkb-BL0$oI|$u@32&1F@~TCD!FL)`Cq~yDwi_{=`(`tRvE~* zYo}s`v4-_wCfOs@2kz|GvnM?kf?hqRkFyP~A>jw*X2I#GSN+&-RSOuo2^Qj@N*7qF zJ#&$Ux(YcI(G0!f%(;OwVDSAB*7eHTsks4W=JUxy%9jseJ1VsnU_Yf2P= zQZTzuJB`IE!nCu`aet#u9MolJwt6Agqoaj-g&b5(T$~dwQ6xgS!UvH3WTEEu+MGPh zSTI-@A%Ohp2sKe&n(7+atXc2BFIDs2GYRY`-#5b2+->%MCl4pqH!2|zl}16R3c+GP;&0R_jo+0#Zk2P~4&o8gpkot0VXD3YxZ^Jko+ zE88we`?DjegPVb{&3<|a_(-e_96Job3?Zd{B^B%+qtB6mT+Y;?+TGfR_jYB&_+(Ks zK#ikPML;%=HTcUiC7}1^db$;bFL%cMvFk87oLg}+HV0&^ma-FZ3C1}uUs_=3IUcZx zL}hf!XG0BF+24}HuOwt_$L8i(=?*&~w$ zg7x+ZuN^-j;gN^DFK>6UG5;*zY(7Ex=RFcTx{ur#v4EXyKEVl%e%K8TW`yP2 zN65GvN{t#JSw$ixEV7ny?)E(Qd`prlwBDy?L_lPlkf74EU?-Koa+H+cGTB%mZ{J#G zmv3re8#pVf0=yPKum+t|9RmNVrq52iD1!AFK5zhixD}l9s&YMJL702G!@XHL>4 zQsZn^7U7w(v!t__zAh-+9DxPPCA65cff*THYCm*4ze=Ejdv))429uu*`dm&#^YjIc zO(FPU#;d=u#_OGa(!L$>cI|){?k32kg_U;RT-jx==Tmz<55tch5AChgAGCfT~n2LHpKAZBdT>=1B zs9tUcMVx>Zq+5vA4dqhw_;4wV1QTbpb2%fA+wo#eF}G^=;DQwt*``;%H&b@FN8$)|{&yLa1Y0 zs7)o9Ca-LJyrQ}uTzxWQzicRmKt=QG6kf>VM2~7Rdu~G~^66zj1UhvM^${Wh+S+~f zmU{}j*A4GW#q!Nru@%ps)MuonJ|n^(A6stFf*(WdHO6Ux&B+U@KKWaPpn0MolBf|Ne>HH&@#OyZY>p_ zA0`Xet$HVyf5_@;QsKex883SAv`jSh4(`C3YK2+uXS8F2h+CSC@>o@RlV(WOE94Su zBla^$gWbGoZ_ko)Tj__7)ff)pzG15UvGcC>tpt5V<|OH(Dmu? z*)i|;o-NC^!jvMNt@A8En#hukMuU&giROhCk{=&R>5;)xhYY=B{ANR%YVN<}8i&z| zh9x`ASHU_|#Hq$%c8?Zk0{vN$Rx8X>0&Is)^H(E~6|D+|rr^UOpQObE1S3>I_~R<- zJ_)68K}`d94+PzT%O{g?q(QodNya({Ib_wbO@$}?-H=r{2r8LZ1j>hV3E6h$f2jRx zeEcg>A4clvNf&9FuaP*kA$miUJC2rb>T$JXv!DZ>3g_~7UVK+3zRBIye(pB1xRyeO z%CuSId6c6(=Ay7ZyPobWMKftWTz0Zx(u7hq7)dmzp zYn;)ds0)@&y_s(G$9Xj#=r!w?qQ(D8My?b}3gF_`?|38Cg(m|RpS^CBJiBJc;ulh> zH6##f$nC(0-{dEgbmgIS^UrEID^z4rX(}3Tf)RR+|Eo%G8~T$Jv6l-}H>Z8xPURhV zR8yj{v$Kzr53{9!S7^iB)wvY<%N_LV8b(x^Ni*)qvYvNFr)$1Hf|{`=4=LF1w)BUb zWj>8}@n-t_fKp%NaGYD!v`*?HnJxboTrm6CIm7-P)k^R=T?``FEvQDZTY|oQ6!@qL zl+E>Z8fzxkFW>T!dyx$AoBhb~S*}G6*JS#d_it8Aq{>WvacptTrOa(p(qvZ~2lrar zh(I0FzACf%nv!7PGW6j#S6(EQd$iOF^ZZ)d^>5mCQ_!j?(j30&S6)|LpR)LJhxtz- zqmA0r5bXe`Wv%$!zUIRVv5wFY28klZX8+3-y=yyr`5U*O=Dyyu-b2F$b;GhpubAhY z0I+6v*nW2w8Z8|2nBZR;k`dpzIqMFc)pfofCN(PT?%`|zbugo0b{Lj#`P}R;#+XUn zzv8ff$d^LP2b>5@a-q+@?|iuGZl81G%#y1;d zIb;oxee>SWEc729BZW+EL422_v;I7VYtrDjcMp_k5>)eIeJ9V%L`|WGyN7!8qeBXV z)jShE_`;;aveFiP8@yvEi|wU^&Ov$=A$Wm_Fb}lul+#$W;FU-26ds;Q%da*nH9q@>X$z{DP0RZlEbS0l z520()xG`D6#(Ip*9RdxHhCGJ&g~%$(X2hv>HT(On6H8^y7ag{}H)L0~7O4xjrGe?V zhyCYzY+znu%D&r8^LHBOUT1s4eizh@;C$Xb@}~w7-{hwKI*0up!WMV z2<1JVJ@H$@6>f{~9MXRA-a9XYGSK+Hg~~UyVl7+BS~J@C9Sv~vIl0}LG$E*ny?yDA zOuL!+m+=MbtK3{mx@N*yp+gMC72QH9Ol;5PeVJr+u_$5qcu zUTW-~J;nx3>KeK^y=t@`SWM%<2zom;ysx7G1jDu*`kAUG_J<`$E$@}tA<0Y?`Dh)G z>iw2qhbyJ+d=txw*_H^y;D)LdQ;(-L{e_D;iB2(bp2fA^FKF3QwA}1psacd?WC*dP zu^W|0Qfjgu>1DVpa`uiUZ&}xgHa!oX;Lo482>ivYzu%zkw$eLd2g;n^CsLp7$mxam z&H4pqs7G!V@pV@ps1eM6u zMD!0CLDQcL>Fu8ETcQ{%U6AuTNjZZN@12(EU6HhB&#)+NmwX3xC^(yIE23R<#>X=W zbrmrDXg0qiTA@ty;aW(oIWM93321j(RN)oy69qb?vqn+=`z7RZV#OM^F~NtOaokZn z9wN)2Ld2v741L+VSCof!JP+&GM1FC+B63f!{)>Ae!D!|~gHZJo1`(5uu6&F#rMx$d zjH$JXG{&aQS-(3XtP)hn|=K0;>V(?$v4y)=$W^ ze}}lp4%BT`D{5L>^8K@ju**oI7=37 zOfs<-o-n!P*gyRXPj*-9U|-P*(7=#1StTJ8Lg`?uDu<6>?S1|KFlDX_Z?i}_oh7!F zdpY1YvaFwX*2;HUQ!BXD`@5KJTmqVfT_@Up>RZWpWy|aO_qm7KXy_Qs8Jh+70J^1a zy*;RMv<+=%USkbOw@YaL9?yPk-{6fE8=2z0`JC?gReCAjv>X5V%>4B;Ej-wHWu;;& zfJ@1=PBJ*PROFl-E)3UMwh8%=4)Ju#N-W^%{BMP{p3;MG6kJmt#{o0sv3xI zpGE1$_z6-<2GTrF7*r2z12)aUy~xarwvh*qY|o;OfzO@tTfeTy|I;XJ6CFe{A7x-w$1|Mn!g>akR&V(?4u^)o~%(40rDQlG@Je!Wvf5Gd3A9}4Ol z^!Z?UPKWDD&TAEjaH9scLNf)ErOk}%UGsH`&l^@weo)#Ol#W;oF@fDJq3NS8!9(5m zoziO3%AgOujc~if`|A3HnbE9C{e)|!^3IxAfLLrqc!PdepNQ)Rmw(zSsKSu6WDj+_ zYjx=HvNg$_;Nhh!(dRrX67uLwHf2a$?uU^&2m>_ri()Dkoae!4{HbG^2%f`gBJ?3tbu@i$*?%^01 zDh{hD%=J}5amA$uc)7_|^Em}kS6QXRVFH}(V@l(Aj zbIN3B|Kx!AB#$)gq6~^!bn|75I@ixJJ`A_8Z*rC;(=MJ@Rd6-#{Cb;Fe*Z+W`@TrT z^6!}EW+AWlosJixE~YBXQj86H;-o0Oji?1HokNapQ1P}`*1-qyJy?^2fky}ijDT~F ztj(VnJNaS|=3ALuVNAP$MoR73T`TDyo7TRSUC4o?yFJpUIN^UcszB{wV_W`Y#PV7W_(D`Yi!c7B{#}Bn077O|@LR+1XR%HFs8}#s}>v_w~g)muZx+ zbDN=mo>XFdN{K05Hq3=WJKksjRxhjfJKIevi`fa5CqClppWQwO-fNfFyUFjN=Vm zuwG8t8X?DM2n0uzYTYn_0QFoM8-13BWonP_vF87f|HXsK@ABOf`*qoD681a7+QB77 zX>!b53;SW^*A-00QuAF`MvW%E_;M~8)zk+O`QDs#=(M7wZm|Pfm4=i)X&zvTlelr0 zsL>B`p6JlJD|XYE4J=CpM{hw58(4*0Q3V5ws^`GhnKS+t zAjfPgI>whEhLr6|`Rh>yc1Nn!OKZ7#-JV~aKUobHy6A2YOZP5fZ1xxX{_%BQWRg6N zvQ*`KwCm3p1BzPKU)XM0crA?Vf#zkDLttMc{G^`h+TBOp`M;$~H)XQX`G>8g;U+5v0 zD`EPTkq7`X)uz;6GuzMNXX@d&(tj4G@TDRC(OuZj@1gG0@+NNpuDg#RJCq|q-yg;i z?@{IWv~wKx)$iCSURwY6yE(nikRI5>Qmx}CeT_iBA9Vu1j@nL6%k@iDZxN|JH>Ap@ z7i~cHW&7~P&Z9$r$CUVzPV6^yOm@;+MXJ+4KmKF1LZeWw9o&LgQCNf<9O6k=YaWTf z-$4Jf$d)PRAu*?RVB2i_ir=`6 zb1u$cdG_2&ZGmT*+2RLiej8b)VLz!|`Pf0v(lUen8&lN56&8u>tq=*vc~{w&*$WfL zU(ltYOTk)MC;R2glUZxC(Q8jvI=U0zsjGpXKGo~fA?&Be?=323Zbar#y}BvDmOu1m z;Q;7=4O!2!iM)Vo$#Dlp^m|+-d!C-N^si(irW=odR5DP?xiBo)f%D$M<-9oh;drzN z+RquIdfGk8ph)l4^=##)&?Y>cc@8*16T15AULCS*^pU6yjc2J(22ges-Tjgo+aE~g z{M*O3xIMnFe`0W!lhg&88qKy;k2*w}AM9Z_0N zd1YwdcyKkPmj4XJWx6pXr!DH1Cm?c`3uB7kWRI4vhb>SqP)E^c2(D?bbGLPycE*`m z8uudkfvf?^oNjCt@q)uZ*M|}=f_%ViSyaezvXuFKL8S0WHQ69oCSyWS7R^Yhpxcio zou1ufRwSeWUtg#)w~$-lm8YjS859ZACTm3`K)s1-MY_(sNhP%omL3VcCnW87FF~Ln|s`NBR+Glf87AtJPGY*10RD=^b$PcEC)Jn z-4)h!Io*b?S7%8^9-s$V2&XEoaY&h)$zjGd2?kU|eaD^nYQy1A%#)M!g(8cfu|7Hm zN@4Hc*Se0Is?LROF$acN?L;}MDlX_QKgE+xp0&0PNUdbsKh4Pl#v#oCus{o@1qh0DEJFQm#N+PvkL(T)WSM40ppB!O!2_@&-L zN%CrL-&7aY6OoRG3@6-ozf)SPJZBL7bJD!CWV5R)S0YSG$mZzqDRQz!_{tYZTxUOq zHVa{@c=P~?o}B8S?)bd2kc}(=s{=k5rzG}|^asz#ky5#?a&eLfKoZYzwsT8y@>uFf zNa~*WjbNC!v)@S?SZ_-HvgXQ3e{u{WFtTzYgX|<7d({;frZ;zE)b50x@GK1d7<3lX za{h>~AU8=>eh_)z<#R}O?4O&}%|r%ZDq_qub$zdl%; zxBH&Df^L4Y{4*wl9&$1_)L8@a*o}OO@B){AbSKoM(3dLoF{;~DzVCn=iAjvVbNBp# zSfb++b<;ds03g*;y6hR(=?*#YCc2Cln@?W>Y*X6o8N*F?<9XW!=^^d(qL(`p2XPdy zZsn?(4X3bgK_pK%MeP(c=NF2B@sMT*Eb$<$QE_cn&tEKX+K)e@wz1khq(Imo%A9X{ zLKjIeQc8+!#T&q!4q{8DKV#r0IzIU`QDm@ z72L|jkzT%dYP$_#FonczpGa9aD>bX9)O|$w^CL)7z_H;r{YW$$1-nm zD7-wV3fm;gcRzjyT@?Y%MtNfYBi}9kTS4>+1Fnd@G!?5LJDpbZESlQ?u{-=B=3k!h z=BM~9|I>f9Ym=4@{~8l@QzLxzUqhZ!3*VFc-xK}!w{&yA+vNv@LR&!HYJIpLzB_2P z2zZ=P(zZAqc0C5fN?g~CfViJrSCOA@b-9j#geiTbZloQF{5*UT7ii zLiLA?(zSK0qwTS_2hfN^HotmmN_E%|=hs4qZZtnvu8{-G*Mmah<|q97+c(b7b-Qlj zg3}H+t{>L!$$-4bIqu1%<%VVyFS~yGR=<>Up}8$;e-A`hz^7_@kfh z9EcgVm1K}(ea_yrNw2B0F#bAw>ZnWCWuwNqAXe`)k4EeAR`Zw2N%g?BYo6I6*3T|m z<9EN0>687{v0uRBp5>UH@bLvk{06RTGgA7=-WkHwyvT@84hAw{Myy#Q%e>fmvjuI^ zQ_y)h?mRV?btt|uD1>kAx!7^w}hJg3L6C8CbTY#&7P(u*nIX3 zyX41(FY7}VcQvl1)o8uW)MJt7VXn&9jM>d`T7z3n+E#3BJn^L%l5t(q3#f&r5H;Pc+Ds_GQGE{ex8>O1+-8z z3unVTQ@XMXAN4AM)M{GfXJhV$?apWJg>DjR-`5lsm$h~4rw7M}WFraIb6vocRZlb3 z#h}#7~$=BZd=6KCMyGPN2lTRZ@BLh0j_(5QGcCq%CLUV3oO2h*? zRb}O8oLH5){MoeDYfRERRf(rOu>=2zXH90CRu6^J)PKMo%%0$&EgWt??>0MZyrZU8 z{dJb4{Dme3bj~rIx2WxHzze`gNq38d&@F(>h4!@k2>9{??>XO3bSQ(!LrWb1ScgRH! zHQ%VRLYuWpfw@Saw`d8U2n)2G`5_f)1UF^!i6Q?e+v#OXM_cMEwU!TU++BkrD)M(q zHcp@G^tU__97E~RCs}=w4Xw*OTznrA>NKz-{AG0^hNzP-C0AANC>znbuO(Q9tHB75 zr&~mru@N82>g^*#bE}=@^OXMZJgv{CAyp$K{F3*!2iHW68@Q!Tze5xf+HF(KsAf-E zGY#Q(^d4SV1(;V~y7@~%S}&KqyBE!^ku;Q;En79j1T@tXP-6}XyNt!@Rd}$a82=W1 zwxb!pNzZgVZ+bs%k5!pkpGdsdw;~t>w#p)KZ-l4AdR;b)**oLs4D3UXXmU@Abmn@e z&u|%@of-9ScC3ci_dTCHZQO4o%0$FjR94?LbDw<=Y}qVHXh;Sbcm8*;er9`>+5dW8 z&djB_bFVLVH3(@t1Y^8Dazop#z%b@fW-SkP31_7r zBBCH{=gRdFp~}xC0Utz`SmZ_`55czvy8WuA1CVjQH>aHx%tZbpQ}9ay5MEk`@~lWLq({AT|8?6^jK*Qh<>3*AqK z8AaJ}{3}5+>;vK@OR2SL4_}!cN!UrwtE06`k1XP^3uOL~%jwX~V#b{?%j~G{u#!ep ztg9Nj=F7|_exY3I>pe{YW)>T+r;Mhclc(m-!@}#>G6>Z>+yWI5$33%i``HZ0OKwuP<}7j%ti#_WfkoQF^qLVV`It zw>4XVvONe+-g*?~d_Gj~AiI!vsSZdM{rCfCl|a)VSQ$*;%|y zX*z!1OVZ0~yFQ%oz)Eh!xJ|5T;8I+PJ44K4?K#Gvsw|m*Msao`o$TCW=h-0M;qMOT z?Y*wl(+1?AZ1!MX(cFgJf!&MhaS=(+00uE%fhDG^iGa70sz18i^{^O4>LZuFUsIxP z#{JBsTH=fq_g=eg&Kec%dzH3}RUasP(R9<+q&Z|z#S|AQz5MF#S8AZLJn%000^U;s z=ueeG2Tu2iM37}k2i_2TVtagXx#qqI)`@Uon@otqD($E256Qa!a?SvTd~L#Cxl~## zsz*n5AdbDA_(EbeG8LH+V#3#leP35^mHjip77`939?WcrRQeq9S}b-w6>2p=I%O$v-Q z*P)h4`|s~w7T&Z!(z|Vw7Q;xPKM+rH?F@{)$a)4r2`}(wnB@(s z%{j>r%6g9F(9ADHluDxdlkT}5;AKh{My_ERXzN2Xgl6(ddu0=Ivz?P`o!&HzdmEO0D}ck>R6CxOduS8W%H$uUm&lfhpdZr__(1#&4mi!t09#xMlhVf)QPpkEtYFz=+#LXQ%#ezLAkj{rHdq zVzB=KH&9j6>KZPVEBbJR&?pX;B-q`EP}f6~Mhws$$rFpvp)B-QwpS1*_TL z!Ew(qK0U~&cXWxxk;(JU0hVVVi=STYLA_$|<|Z`zs9P$E?K4ab>cm|y&#g_6onId? z-Zh0c-?{h6x)~gXfY>Ia1)LK^7phS})j0r2km^Z%%L})lFHP-b4{sD}=D3q1#9WMR zO)bLj9Q|;$rFgMOn3-2BeB1gpYwz+KikM_K`4&+Q{VoC={LK+If9l1Z9iN3w220Xi zuR%6A2IY#pA%8HD_(`a{?+d9?zjtx5Oaj(tIA(+7N#9I^pXJNkLUJqDvW9~a;I#qc zx=%Kxm=3w2oIN;6;oUG_g&Xdozf(%t4doW3E#|9ChTW?5nRIIl0(rB-HFEc+0#$;o7_0rIBCXmuA3{ z@*Hn<%rIH_zIMhs4$67Qq9g*6U2{}CVt7I#n(|^c9nRbqpsl^t{zyFHG~O*=SZP zz;+6yU&Pe#Tv>V}?o>A)nc+?}Ucde&@}XBlAKci9p3u<;$mIfElO_$(vyhbozF~XR z`zz^MZ-CJ2Vn3OT^~}Hy;X)z*z?i?|n7?HVC_{WQo{{eFTI}i~(*famS(3LY#;eQy z(#5=vt=olFTfwJcXVTYk##LGsMJElgX|F}TUJNe-tk*-2a(iIT;wxo=DGc)!wYWmd zcJ~xyE1@$^NWkvQ!d`T40Z$={IeH-Xmj|g}4*5v`dU5@{I&ClRP(HJ4i~kR;cR+Vz z>U3CO^c~E~*2Suo*xb1nIm=#_c@+ZYx2W+t+g#8W9MWId(_wkFASCps!p$sG-o0YI zbvi8TXF*Qq(cJz?TLMT27Xggkr(kc|vrG7PR^l;KVwc`z>CUqXW6;TwK7W+Ktk zy{dNZgK$nIp}LbW);0CV`cVj};s@L!Ym3oJ{w-okwqbjC$lTkXqV%hi<6v1m861zf zRM@|u`D4y1#`@fob|K+;3kfH9P7!G`NEnQrvvrQ@*bS~9EBhY85id%nRYibT7+M7WFE6Vt+1h2H3H`7elwS%H$mVDyowy@-F{ z;!D|z@y}$D;-t}YA8^dMtJ^!my-rFPL6*h)!e5$pEjKc-4$Q~U7J09eYm5^GT{z$a zy2;rOw^`sP-RlKvxlinhMfO^HzWBd!a}I?QVZnRX(A~pXQ=4X2)ZAW{{vqA#TR~y` z2W$&UGn`S|qB&;m4mf_OcbDu-!$)IPRgv=dZ^smc=*FJ!a$1|TNfAnqAD5+A2Cq~m zHneevq@a^1f88~L*BQjsLmPaS!xqjhKu?-!$rcwUoP|Y{221@tImm!zYQIu6Q11h# zx0O!jP#c?vcC9w^XogUJM-62|j_`}10Vd~2&wC^e1Ku>^bYL+5wSGMBnH`-GRpD48 zx0(EOic{lbSbD}*aK;|NjYo#(WiAVM1Kvcl-yv^YCvN!%iyxYATusPj z@{l?Pa?J-4ULT|GOe!EG7U0}SB5XZiwh9{ReG%$(e9=LU#_~p_1;ea^A7{ufjJl8g zeA&^>Nm`^2QjNz=zQ4Bl$-!`;ggUT9W9Pi=-%Qd5DU9^9x+~ILdirg$7NS#t zcfmU1VqI;r<1G_i^{D8rFq19xO0wZt5iTLj!M}YUrvLVJfDo-*Om#yO26k@kt z<>cjymnMy48!=lVC{O$l6t4m{tw&`WAWDo7Dw&rbY@p60%08x zLIRscMScCIrFo|0$M)~w)!ix=u$iyK>bU3KJU=0+ISW+b*1fP|i{ZAZ#xV4*#k;C= z=LMk_5g+@f+6pvK0bj6RXuqG?xc~h>kcbq9sR?WlkJV`4cxc=f?mfo-kR^9O*UStM zYSwZ`{wjdpdJjTSsI45F_Wd4paB({&5^}F!zww9uWd$k@1~f%EIpNug)@(O}Y$8WF z94+Otr`xl$xBhljav2~XNv+EXsLv5c0cK@Tqy12@rsuY{SiW^R;z#A6X4t-JwPV&{ z6}VP4NrCsK`fwLVVDz2@c&p81?UAf2Yf5`y{uiaJ*l>P( z;vJoq2x;Jgkntg8?Bvhv_rZStZnV15#?f;{Vj0pi`Kh=lYa{qdZa2n&&hM`8|9_Wpib+V+X|&`3{}UhMn=G{jw(y zn*}5kIQKr4i2Hoeo+M#>h*Agxj*)Alju)oRL%GatcmMh~O)?Nyc&+3YSD_uG@ za|W15)?~Nx=5FEb8$=W`Y;vFz^LCUo6O4BkhU#>YgCg z4hKw{{EJBNZ=VDf?vhkJhsIP8D~Btk5?Nad#8=#veChQ;^nB zQJr|X^B3pvR&$Qdkp4a0o@-4e!&FD|4Vh(n3{}{)h@jY#F19LcXIhuf3X#f-wv%8r zzKu|GtGb!Jg0rsZp8fuqQqn@R6<9Q0YTxN6zl@XYRd55Fq+AHJdt~gS#ostAnFG6a z2Vcp6N3<;8u37up686tEMuTcv;dYkIEGcZRDj^ABH_8L=8Q`dDCBi}^1k26|(iQz2 zHxF~fV}VT4WWx&N?OC|5av(o}&0Xh?MY4}oj(!dnlSc=S=9@&&-RIyim#rNrb*AtY zIQTdPFD6kD2Bbd<13xtlX2%JyZ;<$AhdI_u{LCLXLV;x)0&~I?VU-Id-gslBSmYoim%y z?~WzjQO%cqah+I;=~V_xcG;TvwE+!GZj2er-=(CuRZvji^J>uJYfAH<5nK;CYu>6E zr#FzWjD7-0vm5!!g}^!N|3lpRcwt{^XoP3(n5gngq>QGOh^K;LJl}IrnN!wF3C7PJ zzMGqn?D}x`uYUs44I`!CH1Kn$UpE8?#c;?7fRfc=cB5_FjKKfH-g`#F-GA@GM36*H z(OU!&z4tOiZlWiM=%N!w?`?=4HF_7(B6=I0=ut=XE{rzX=z|&Kd~^TK|2fY&>v{96 zbHANy*D)p?PAq+agi!vhSb<=G2<=Gk_MOtzovANRO^pnP2(qr zEN0`h4-^+WP5zgTz5nwt+&)|LOt7q{oIx zvx#DEvSDumKG1nU0AN4{pbE@?thIJsAuX!Tj&y?$61kRD`+^(y1v3aN5x``uaDUwY zdTt`*9KbCkG}zrmRuYFbqm~AGZd130O*Tj~;-U2g5#EJB4U&GMqwbuS%BsfR|NSEn zem5g?e!TsIT3^T=F7z;^w_y?Sbp6;yN@H!qsb!*hFlLk3-vN(37N?d+1Qgo9CbvJy z+LT-qjH(L@|A+RLgZoVKLq>|#Sq=*2`&TD|;07oPLZ4BDdl*9;%m~5)sxEVQ%L25Utv|cH9uUpdRQoV)0js;dsmei=Y{mATUohrL z_s{;&aZf%=!s**Ey=85!9ZT|^WIdG(ib9pL}-BHHgJ!#K_#hD}ycpq<+t-+Tk1~nI71bM zxRMT*$RqUA)d#2;%gYvXi*J3eZlzxLtiMm{a1%Y~iK}h$vY>r6%`C|I`63?tlzrVK z(UZx{j0ytNy*im46`<_~vTEs6fs55?_F;L=6RIJdU)bI+4t&G~u!YVKE5*m1FV|!r zIpheCJjcveJYHUJzt115oq_1xMzv?xKdmH^W88y!duQVPT0M;e-wI78hCoj^*CS-k z+C@^=I0y%c>O5?dH(VTUycbqiqSgCfR8!f!Zf+(0at_$lgu;JY{WkM#4sGb$Lp!-} zL?<&R#a`=R-1{#L!jD@;SQm#G4cXN*FyDm?hBP5vJJg*L$puk0y=Uno7jSa3y+R)8 zE8b`(2es@xKblmw^i8F(d7%kvVp}uA%1daCgR1F)pGd^yaC5681Fi7Ko}+X2KMQ{P zXTMDzIlA1;L2x3ULNxg@2S}LNcwjr^WFd)|0tfol@FqgB^aZbQ*X@!(lf1vEKxIu+ zc00K9crCSNU&R#mHS58=NC%0)`5w8x*ypWwn*?1;ZlCs5WXdbf!AJCIq4gE1Fb)v5w`-bE&ej57ueVSp;AkaC$5*}(yQ_e7# z&}|I2Tm5x!mZ0(C8ArpoUblba;^YM7_53q~0h;Z(x?pfF0UIyF#tj&Ho0Xd0khyyM z{)mE%r?55AA|ke{KT-PGz#-e-qC*_R=*HZ-zdIj31z|7**KAQ_!rMN`1ke5^)zTLa#Ei7nR1^~89q7d+WKU%sJ!4F>F^Z2 ztYDdeoZ?$eX!#}ToPC2EE!2L1-;lRGFi!|{i(R!BH~LHK_yd|uiAAuft3fQnp_#;8&A^RlW9Xi+ohMAaxIMg+Jz$jEWGUbi1ow7m2^I}2ccYQ(tc=>5 zT+jd|*7ODLqx@?u1WIj;B3KwMc&!Tq{FUPh{J)W;{~fhucj4UXqvKxkoYvK%+-F zOXJap%KeX$14Ju%+p{f$Ui!vacvAyBaWLMD^T^qhvocopk>5OR3$MAi!?zl~*9*Os ztcQbMW8NJ)-8Iwg4it`%zpT=qiMNSp*dJ=1*D5r_JdBb*%L=;d2iajbA{GBP9xD-jQZh-M;UxUL@2fVF0}iAg{HnCp*pZEgU*zM zulgAUBWsN+2ugLQrfz-%`l_YK&q5^jy`m%s9BpFqawm!Z6aQD|bA>bGfT?r_(*DN` ze$N-JBeNuI-Z?8Ul?ZT7^WZ>O0VmPeVnzLL9g)`f$>e13L(c)VZf=Zp=)1Hp@0yD|x>LB-*osuP4w^ zIIg-V^O1`awz=Va49+hYpYTc99+=A8Um{=;bt+@V!WaMP9t%kr4yHOj%4=02(T7B7 zAnDdAQTq6)8QyGC;6=7em1YjR{dQ7)B`zT1(TLB23zAQ^O0Y(zPyc&Pc%Qj?oX;8^ ztDH1%Ybe0M+U-74^w=R=D8e|G;Kz>%AQhM-r*u9k#)jDqLaQquT7M=?=!8f_ix#HE z5x(<>3{C4EZ@88TrRYObon3E;i@^;t98~6oPH630{j6MzL&nC8dEM*zOxdyC3mn3X zIeS6$DMN+Z3&|k^^klj9<{+}l-KtN42*Jx<6%@&TZ?7>0JPB9h0)>)41m)fE(S*Fx zciXCNU*hV~kMyEUPAPdOX0`Qxvtr=x(?1y5xc~$JMkqLi70LMjl43r9Fv=&Q>>6nxGqCsG1@Z>YI-gr)&)y{QA5YwKsL%rqhykX;9=y zGh7LXjyWg%_Ou1HGUa*4tHz~-vA3a1!`%Q|u@U&kN7pBol9<*}dO4>KQst&eRQk+j z4!W|F49+=fTE&xp)i_hoRdJ(dcy>?OB3AgxBg#W@?)5L{4*OID*=7e{`-Q)+ zhHmj>lDjE9F@8kmI=qiK!+n~_UsV^L{kMpH*Ns({(a**^plV-}K$eD|G?L{9AVvC5 zq8?w=R+N_*jC{Tj;HYHc7>K_Auf#e=ljx)Gr@Tw8h{e*lBU_iMXcAKnZ%7m)UjMom zjv}`F`R&t$adnP~#_9k{e(s(qPy6h45R78GRg5Ok=C3Yjo@Y5L+8Au6it0C~t%`Bi znnAVGH{`%73n}H8?V?cQf5L4u8*N{nUcVJ)st;ceu2Z)CT%-Pq`e&8e`8g@KuOG+q zC^&4aF_OJ5BxWqD9~SjyH5Lf)X6v{HzoYjd z2$DtZ=fN#%Lu6TzN>JLX6mYkq)&T#n)q!3|_&l;AspY^SF2JrZKfA8~v8Iuvyb3b6 zaITT$0g2mIE1(P7BW{HG{|TywPR?V0JY;v(5>2p%(S>};yLJl*zW2AR$-By?yIt?0 z(Q0XAc6p!smeDH5OXIp`H7|*dIuP~xBCT;!#Flr!{-SUnQ)GGTy-vWrt*892x}Ic@ zER@xp8Jn*s8@7OGy>BdGEW5xamsP9}ZtB{c6gZ43BUI46UW1)epYBW54NBP;BFl{o zuRGj7#m2A5cQb0^=p$@cJ_T1fiV)}nvhaAtTj8dL`=f@h9?u^!KAJRtor(Q={M)|g zUJ?D(O-kj6w=K`|a$@x?b+74KbHL*nx5A3s4dV$}SBMyY>bkMq(AeaUEWy*V4^tKx zOO8%5Pg7q2EU;lBBT^_Tbr(5PPyCp;FQ^P3Zgg$saY!PunS*>(YN{X5p2k_YtUN|T z>w_VP<$Q|KZw2={7iPCw9i_1qH<;m%fl-UgEtf*3>46RCZ*%d~dM831j!9)LOCsKa ze3jXw+VR*gaTRorjgho>nkgvYkEo=fGZjWi=B>_a-1(K~cZ@tl&a>(*cC=5ue`KZM z?(G(Det1q6?KTg>Y>#-QcaBMnV^+sH;)4UOUO|NUJd3=4~3oF(>fiyJ;u$ zSUtLyTQ-JB%keTLy*=ovO7r>}kHvgmL)u1Qzlm+w>gE?yYGoA2iS6dJ$h~HohIU5m zc9g%RTwMNo*uoB?{UUjTX1I9i;$E~?-Mq+z$^3=a{jb1{zYbUwhSSx<$U8F|;x8cr zsS|HrC4(h6nb7)Dn4uedx`K%%%(2%Xp#eLC_3W8=e5{t!iP)%(1@pMOWUEPFZxK9b zwg1b9ywXg8p1(m{7J87ia`&m_YtVwzFBrO zDdFB3P$#^wT3C7!A&;6JT{>Hq;t67=o+0- zEfJ!b=E;V6R1ALhEV60E^8<$Etl@UG{G$DAhxCn_K{yJRqJ*6G%wVX}(&C@57H+An zBNL?i*Oktz?Sn^Bt+kihPuCyL6jznRm+KW&nx6oJ3=a^op%F^A|2ETl2eQh#B>b{&MQhuNZ1^Xou{H$S} z)Q*4W2^m_|b@7^?&cJXO@-eVljlF%EEL+u~U~YIyB{T zcGJL?N)$gM(p_k6%g{wOEop$X1|C_(9?dle3(NL^R7lVISqf>!XLX%A(XU6>fllg5 z?wtLwx1DJK0pub|q3cUS(S?@hRR%T_Xxh!b4eUg#{v{%{dls9Cg-xZsBB{2KC`K<_ z*Ne0c-qZNS+6_{Zb^8=9&cbn}wQZx8af~_hRhVq;zScnK!$6t-72n)vtUnQtsTD8+ zOXgfuZ9r}Pqa?xpTgJr>mR_*BNZtn%$@I+`JH{0oh{Af1v ztI70$-1YkbS)#X%#iNz65a9L=@D2@Q6r<;{k;Z`Od*1uY=^px=bby>jz5z-Q$x9XS zRL9Uguvjw^M)5Mlq;5Vna7=s)4H%$Ihkh=0|G9iPf#YarUdDRG0;C6OgcF@ATa68? zfX5y+dTkD}ZatUUnjvqrEct2%yyv~pr$zgNAzZ-oi$Yu@+g@!PHKfii=Iy+2YjR&P zp`mRp#zsit{ac&PYA?ne#0#cwaa%q}W=^mMmv z*~Epr<2-9Q6F&K!$X&bCKYORSgX^8yg5|nFx}-YDRK?4!K;S6o8-TuWBwZ4s#+{!9xAX7GWLGmBV(v2NGo_7wTr4f}It6?iWagUgg_+O=uE4c%=?WSMhT%{EKt0~xD$>@ca3_&79ZE4Pw~Yt00-uBh?*yPn$7BK^*i12NcK#Ds-D?93 ztrMmyOs~OF7a^~<2ut=jWFj@Xszmv9%-zt!Ktd^B;$eTTlD+0oh*`|A^e-meSy<6* z$C{0{`@JfFPvW8C><7otuO?X+dwU))F;i+nNET)HcpqRbu3f)~ohHPSrVkI6M4d-E zP^*u;Dp2?#Z1bGbt?Uc#w$si(mN&MUUWSJr1Uc=G)K2@MBQ4A>F8zTI?DG5$vtL|c zzP=4=M9R&#UIW_Sv`)160$#TCeywK6-@9C7Qr(QElK5LWMG zF${N5snuH=?Ra8Hm>8gw8B$vK{pU^5k8^~HCvGUf)MKCQ;uc9o13|}E`JgD+Pg?j! zJk{_)UAreZp;NVpYi(oQkAB#&K~r|5L=`Eq`$|Uu)Ll~ zG`Cy%PCB~nT`6R~8}pIcZi?YV?5yCb;yjZ6taz4Ph@L_3;Tp7Lfp)Sf<#^&a$%LSS zFXvCM@{5L(xV-)UVLhZgmEbyNehQ+{+Q%ArykD+t?mkH~R|#~5jFNRVf#d;)K%mF- zJu7?t2QTXJ6H)@9wN^E5ez|X<&;};$-M?cn3_hm|RFx~1v=@d@VY zz`CqY-=%Dv^tBd?NVLop#Ua3T0qkxrq|4Krl@9?!5LVIR_K#zaPyQ`9*=G87me-c4NO0q0ww15yaP%+NHQ=3u1M+jT|$r5Lk|mZf2kRa)7z%XqgpR+xFaXJj$QW-L+<0A$Q)8=|EdJWm#bH_4xp!9{yLUbA0E3qkyOX0iTR{g zrXQ0oo(3m$L1E3!?xAFWT<+#&c#G`*9Ul|GYAy-AKh*x0xK;xg;Pr57yL%|vC#r=X0h@u;-o`1lV5G0?aeTEX~T<;j464g@InQ? zr-4M`0B~#GkO%%7gRshySejz>JnPxtYHUe{o`9Wm%ku_#(ky^{PIU{tE_@s4v_8|`pR zY6W_0!@R6-&cW;b`@>zjWq3ZqCagxEDnNJ6B^sr_H05~ZWSe%k~#&%AJDqtO2&``SReH*g;N(%9;wF6$2T!n)SU)Bit*>2V)OM3 zUzu~H7HAJ<8#kb;KFQvyUO#Ji=wg)hD;C8G;sCxXrPvwvdYw@u_tGiHJ-vRz?*=I> z6EJuDN)26mBGf9k8C6FQLIRWT35Pyko>S z#1EkrhnZS9_n`uw`h)1J?3By#ujk8(J{uJWjvxI(d-V}d$U26PGrLr<6N#;0JW9Go z?+m#QFgk>s{i5_&m3EN4W0^C}OvW8vL#K}lX!0Pj`LnnIoNvxBU5h(LzZiPw@BC5$ z(XE0%yeR;H4Z~m{na|6$%B#W+9J3I_-V4KuP1O5LIe319`|PJC-u~7_F`vqTY@-`0 z7GB{U`LwL1Rg$1S?``d$fZ4Dv!BoF=KyU9~v*Cd%>iJ)50VSZdIal;TKNn$Es8nzI zX8sL!-)UyCi9<~kImxR=rV%%k*w)8MCHt_<)OUWUs>)&M(B+Z#o?LVIaGlR?zW8ny zS^av!!TyjiiH8Y8pD~&GhvQ)m?%buc))C0pBXKDcL0axjvknRorx=Tu+jOyA6H0mO z!Oyq&bV3#mHC^B1a?PSqHv{7KAYLFqK{7#c>M>obJE;)Mmm>{v1-!X3T_yugj;bZI zc6}RZ+TM;Bege<1PA6=r!{oV9z^P13_LuJdv^;am?W~;R??>Wj(JQ_6MW^MRofC%O z>V9GsPf}_rh_&FO55t6(>4LGD4=bpJeV{yJ&~-_4(|MoMCAwH)j7-;!S?1PZAN*7H z(e)T$%aK~;y81{G&^pRYbdOFvPZ`>-WGflh>m9=;h$c0gw42zP!|x!=@=uK`lfK;T@_OWB;sN$R5qJ_%8HHzPbD0UaMXKFLD@L;3$q_C~zM z&e7ag1#Gb1tN0r@0#Oz+$SB|I@8FqtZjPtu-&5Ri5JwCWS}dRLL2-8aF8R>~jM`3z z)Z8X%3rTFlbzFVWe)d!Ek>}+)_hb?s+lB+}OOynAP`KJ_-`=SxM)|b3{+mTvYt~jZ zyA()C($Z||;reZNJrpo10~WR)T!7!%{EsC;7;c`1^`C6_2L~w&i9!y()&#`4;~$#>IjAa23h z5&B{;N)oU&KeBgO>s&!>va&MZckk34JC9o@5vq<)DvR89bWLLDSt0ns-ifTIGG#YUAF~VBPv831Tr@Rf(==RHzbKZx2L!9Ry_otxHLnUne#$Jn zlqP7Vp}rx_7ki6AQp3`!!0pYkFiM|+8mB#H0;0sSyO;3hvLi)5@UGHp*dQ_4=^LB7DjJ=`g}hMVC-y%~ zdu27u{bJaEgf~H_FTEr=@b-O{Sa?0umFW0va03X-U3`8N9Kj{d0+x1OAq4}nEzW6G zuS)y&PXE#vPi9UeRFCs3DwfUcTT3J!KTO_8vUc(t4{nD42&{c!R6Ogz0g3vG)MGIn z(O*geba|uHQEmyu2wjh&#=ye>u4C+7prZ#Uc)3$5Tv*k_7KG4DNM%=p4%I~&&}c>; zG*4zI-<*wFFlaj+Q_0nBDBpOWx+{=+9mu_f(giC0`@jvA z4Y&$Q%sDzyeOizjky|0>%bi=Gn@z4o6 z?Cz{gt}*6>#nd4WcG{YQw~if{{W+@a8@}+KuzDn0j9qNJanXCi!N+Ot{7dMQ%`Z;3 z?QktmaA2>8Q)qVxt7i?5A8zQYSC15#udblyhK{sd(3?{`qzTOJ$}sZikrI*Wr4kZH02&SYrTQO?r?e>5O zIhc)lbI;D1>md|LWI%~R+c7gz0u4M%PZu4UY0UutQD)rz0<>yg^-KU{PgTFr-QGf{ zMW(2Aj(xrXELmZUO%{slW3@V3)04VICiTA2!n1I{jk;e4FGiq(c`^={ilpI_3gJ+NfCpYkAL3KZ3jkT0XN`rAIFX zu2$y-cm8q*O=+Pg4b~yRAd(Gx(M@HqqRUgSie7vsyI;{a7BTHn?WRh9KG7*YkK;cg zzg1i9W5W8Is-lX^g&jF^ym?zc-2@D4nXMUEUzz^G3h>a>Pn*m~vwnjja>JH2ha&b6 zZtcVJ&K!9M@yqhvynF-yNO$+g0|Ii<6401;DN4LxIU>%^niy&CvQ-lLJMgy&RK`3v z!TR{xf3xIpC=#uU&!WcQVG8mdN~0e@Evl?MSad+wCezp~=?`k=1Ek;C0TjmqEm<(u zOXd?dmOWEPR>#%l;reX=D)(kMz80Uws7p1_>Z6yBg962&#YE1{oUOfK+K;N{S>^q| zy`o<6yocGynCE^qjTuYql9$@x-$=KsL`qmi)86N^$isv%SBFX zuTcZ}lrxyG@|#@oqO#Kxd## zYQ@Ukbkj>N>a~v&e|>>}+b+?)Atq=S>8?02_=KJw0Qc4c>B9_KPZP^8I$5md7FPmyI&X?DCVipt5&W9mX}< z#^_8}d$->%w5Nk)Ix{-+Fe{q~+n{LB?duB=b|@Oq%5Y5E>cdcRn=SNi$0bZ0^*mot}5X@8#eXCzxAi+t*NS~pjnc6rsgXF`V*ySq=LlT2}6O?-`sDKYx*&>$^fb@n5#En-9Pszd&vVj2az(PXz* z7`b(;jj1p7FZ}Nh<@4TaCSXsJ+@nO8WUHzOqK^} zH03v^?ZRTW6?25j3)x*Ihj=XB%^kHT+GVrb>Hz=5QNH13a@d%1d1TDUW(Jk~sB2x? z^@YDYZ&yg%g`i!I!l7Bfh+lasB5o6G~zH-Y2{)*9xhPT*lXT) z`c)cm_rLgG<10Duf46yJ^h?c6mt>{AIuh7k-nX>c$%lwMzjQ=~_0`qYE}O=tmgmYPg@%B|NQpy+#EIrZ*p8T7-Y?{ z_PooG+Yhd!27WVF_r>LI8BWdBWKW{N}n}=e{@h zS*Qa0@l{ch)U$-yub+$dx-qU(8DjnC3*V{Q{aR=zZ5y*azgDT!Mb3@B89;IEmEcEf zHZz&V8Vy+<9#|Qdgh795W+9AsO`5R1yxdZ3SJS%pwTzs<2i3MrTqT*}>p)Xk#tc6b zQB3l>uf1QYGAP=;V>KrkimgrC$s=-(Efu1(qr^hYQYncKC@(5THCi(YaQF+scNBhns7gy4kNCt;g zE;SiRV(I}Ri`GfWU9l;5h~W=80;YB()_~@Lr;z0H@Rg{n!Czd@g#|zVx_%o<3Bao3 z`-i1WJ)vaMI=Z}8wM`0lQEI{rR5TCpX&f|SO3Whuu{7W%CVHLYP8i=pwK1*NMW{LM z`O9?K)KgonznoNF!;X92l!b>vh-3C%~O->^q_vp~Z|;!~8Gyc7vFE}YAb9U*<}OYz?od96-V zV3i{>aBO(lNE$qpi3GW8E}Q$twV-YHue z51xh5shYNAT#ozKGZ7=wZ{1fZi1(_;*S*gU?Ys#KM%T1K^bZ7;jThpdlgQB9{5{zp zX31@?8wUny`z%X%&Cd=5h?gnmt~k^3Hj58Bnv28CPh=NILP4*_d$pz8k_Xp8$-Kyb zG%`J zoj+E;a9}lTQ%RjVkB2|1sNQK1ZKbJjuRkEAd#l65&OJi6iPdYy?1;IZ#;kSm53kcvvTAxW>J)vqDNFpv?HdRzodv_Hm_Sm5Q!x`G@ z$r^CrQPc%rBj9QthSise|9jSA2pjM z___tc8Nny-UG>g#p=W!~|1*ryase3|zO)lDI-t+f>Mvds=@=lYEho-EG}rzdTn>${`eGO}5!SAU$SI+nAhp?cAJVjBm(T zgeq$ulOFSIA9KA|Yr5YTYHV`ypcDS7!O!?`56_UHmL2FpPi!A%MPN%UKfr{ttvvd_ zX@qLa?WVJ=Jy007*g-PaQ~6^$yj0M{2MY7T*`y9VP&mF9EwGJK%gzqgXtR; zx=1HOw%0CQ`TvHgQlJM%NFiE@CW(QLt(s*ZId7p`1l{LN(B_~4pMkd(HIZ+4=1W1= z15*SyWAUKA--({ke>n3l7&EutG%-|$1D3Psb8ck$^uw~mlv;|X=;(Nyou+3cI^GKq z6U3osLYS~6;+i`JE_+udSLg3=AQh=G^VRsIPOGvn{jn`Rz`r`+rF{P4IR*U(cVPAb zDWR&5;=5XMvI2Gno}7L)5Y1psKOqDZKeYiJ!yCoy62@3}gH-ZXkG0*Cn>)p1X|nti zn!)=6_^o4>6`1c@lta3EYnkf(12{Q)8QRz4a2uIrHKnE_E?(+S%T9m}<5Q3?@vxMd zhKVjmItN);g!aqbXzyz=#sTYN`neaexsTusFW)}M46BE9i#~Xih!U97hh5c6$>t~xM zLvDL7a2B|IBML>w^bPW3!)vbEUwsRXp^1Ji@~)QDNtgf#HL%%f!S7JX)n{$X+@4jp zEI1j_vtP`7`TjKh(S9Dy?pTbo1I0e39LTax|M$yLDn{I~AxkjW;;vEA$yVL%HW4mg zo&0_`)eIFqsOW#tksD9t$ui+mrw|qB&Q)5Mg+rLmj|@V5Cl>9@CJ7bv-O~*hbq#-G zTX$Eq^FyfS?r>FWVOZNa>{JDJ-1@!~%+a2BX)X0oHfwO5OpOr#sT+d4IDnw0(`g;6 z+mO(H;bRe%C93M}r}EgQt@5Ep);ym8&!ZQw&2ol`Ps~?VYVr2%zUOc-&K&fwdpwT1 z`@qC#yVhUh7-|!Pn-U(iAIKj3y!h3V6-P=ZK0(f!Rx&yUG93Tv$U;~QY1n8M0l9w9 zg>S6$!pO>S*>dnF57sKa<>0m7MR?q~U)CboZ}y~CU5nv&OnfTcmEJeF4b7)BwFdyt zx$6Um36sgdy3mE6OpLAD^Y@KB%E)xK=6qrMmAx~=?r}Z$g#Sy3Pkcnr!OMDWt zx{2hcqpak<>xbzF^-IzXah3xCs@y4*x*Ab;9_U^D;J`Ot7+t3VD1Bew))Z6~_j&e! zzAAj?L>tN5Sj&~hU?0oQqO$1%b+tt$+kQbW=Vc-0p%BC{DZ^QctxeknO!7od0=ArLrC%@%(l3_G2%Cmy3#aQ zb;8e(hWr6OpN3h{Ea~j$axY6M_Lv(4g9*a1gKGM9m*aEdRW*5U?iv#oXaf}I=XOMF zi_L&2R6`tuKlLvhz2~cMe1#dRy5O#tVY9HOlAH3+tx*3$kz3&ceS?{-K?3S83^j#QhdfxsS5i?jOkDukhK-Mz zj+CP<>Zx{K;83&JNqJPv9-{@i9y|}<1|vrU!l|CiypEt-()j2Gc91T(#XKR}mb~q+ zZH66nPr|7KAVifOAe@&*VAw;%2w{`j*n zx|KOxCs*U%hh<+<;Sqg*kWI%ONi{ zD)SoeJ1zfi()1}2c(0BZN7OMV<;n6``Q*tT5y_gSqdR^75YjA<-;Dl9YX8Qy{ODGN zaeM<9`aLiC^M>U)Q=MPdaVKv4eC~q#WFeA26Q{50@aMe7Ae$7~(@a~k3=Qw3=l#ub zmM!kjZqLCM;TDacjNAteWe3#*ytA-gw4s&4Tu6Qn{&fs^OLo(puR$Msi+Y^sj?2^{ypB`^$R)nyqnbMJuLVR1{Jd!H zy!Pn+kvO`plpTB3r|9Bj!DC_r`J%2TK8}iKJQN{cNsSxjpU9L+s?+oIIiDmofzbVl zAIaS17=0yh{RjWEYM`Zy4GQ(+P0*(GF$zC)CjMFdDL>CLyIUM)_t* z{PWQ-GwFTi!*HzR$R(`QNZX(~w;vCt8PK#pR9W>)n&zJu>fTRR6c`D6p$(%)W3t;? zTZFV!<;#1nMtR16vE96y{3XAM{sn)o0oZCt2He}A`1@7bkWjbX!pOb7n32gtCtVVt za2{%h>e`H`E`z1#SGj}pq3mKt07{ZG4T_(yS+w}BYunD^X9wC2t)Xg@Zr0k2VXqOT z3Kep{Q6=Ul`;n>tkqE~!$%p>w=ebpUtunImibu&!A1L>0TWYT|Oc?@KzuO4D=yGdq zI_@C`Z*dsOoHA@z^o4ze5*c68pvrVa!}`=CiQ~;}A!I)Asvht`hl8u}xBM_aFh{K6 zAhKenuB}c-saz#>NH<7L5QJy@&tyNZ)8!Lq0C5G(47ngbF#$z4Fz>LG!KMKj5j3oO zLRyU`3VI&H2*@ejh~-YP%jL!oY(TXk9~S{Su^O*Sxbwcvk5RdrTbA z@H1Tv+SdRqHT8I|3XFNEHGulQ)orr2rhV*SAjVS=yT|$G|DCn6rXxQN6q@ifF^}kA zZhnvoprn5_`57FIhnh*dqdhP~EBv=0k;`{FM2x-;FCfm0xIY_E#8$gMJ%yw16%}87 zzSSfNr0`@IJv`uo>yBEl3PJOxkMcUm@X0|iphI;Yl^qi1OF7~_Mj*C6R_W3PmawgmAUH^8b zfb<@+3zWj{@A7EG>4#8+da?W*vcozaj&tkNX<~aZ-ZZfm%u_kt{AH#+T;PxqwPj2U z^n-Iz%vSd`ri(6T2$xb-S_Kl5#qcnIav(OWFcR=K%ONIwE@Hu10lZjsTSCxz4blne zUgUslTLfB^%)KDx+gD2Bw}4-bS5yB>6`|{)W2fRtrxvAbknGPK-0xOwlCA@J$%pWk zrQfA;|9BfQCx1G3ycwVEWAklqAN7&5Z4+m!Bk3Rcu|+joc>c=|)_@(giHAq#P-HfA zZU(2XkP+&2peS^mL+=_b&An;)9$3eVnk@$e$67L)Ft?o$2x}y>$??mqaTg4ytgK&f z3`Kj6W{IzkoV{rRe=a@3RyzPj&F9GgMh0 zxp2xoUg-CAtQ57gUP7!-#bfAnYFf_(9v`Yc&jOw7`mIcOydx?a-PZ(u8%rzGX@cVe z+2`j(LADD}QL&qEcIMGRgcH)l-Asf06MD8>1`?blrSx<7<54RNGOK$N`$8-E+V*?` zSg5Wdo7g#y;%tc1d|rU%2D6z^?#!=HM^5^$lQxZaf`foOCeIiva1UXN4kUs|+ElNWzNm&41* znF`2IT330dIbyWraemsF?O216fxMKrWqe#?hA~844pf;}3_yMoz7b>CR5OWNWL3_B z$#(w=lbRS>^05R;DHXWxiC-B|N^$=!rHX1P#E+XFuEZ`*h&vG$AV2d@ynM~`?U1%x z#gL)f_@~{kiF19ffp(%m=~CNNz@X)sJ>9{i@YIS6L@{?AV_~<>7Av(2EoDOg{!g~* zijAN-?Wf}^^)Tw=j2CLVb3k~uu0`NBKogS3@V=Z=B!m0Ea+ThHauuV1F0;b~^nUZA z;%`x48t81?zXWqDifSI8n(9BL)RmT5$f$_@ZD!^n-yL!lHGikWwiUN+rUs|yhk>U= zji+x2x3$Ya^s9Ys9I%A?UA)@Q`+24EQ(&)V;t>FRKwCxPrlexNI!VpzUAlBQY-q3Wx>nNH+xSG4L zNuNy`)VQ~~;O$%AGIn7iuo`&zTv4VDHXM%!HKG*$+-9y*(Rk2Asj8$5Mf$b7jjxU}~$p@Uo>M~%JON%LqqEl zss5OuH!z>2FHZEnR4K4q{=0b$b<5(HBa1GzN`42-< zHgvXSN;(MNFzPrC*OCZm~_pPw@1gf1N>RFWq)G3+*Pt}%y#^9`3!y`R z5OU*t?_KNO`|tbzWvyhbB9{SQ)4CXS zqDpvf2!2uQ`@fd7^Rw>!!N9C>i@*J5!i z3vCxRVXgALL#um@Qr3|6O)c6wVc#Z5uk?$SGgS_-dp(=m$AkKb#!@iyY>5*Fcflmh>ISJlcPuc{`Ajklyt0+s- zb<`-5y&nyJT(82x!^Q@5F;7gl8?FU=W0d>m#5Hn!mv`SDb|t-3W6+!Z{ax;y)*a{P zsJTMv2i+>JW_;B}IDe^xt2;pvg5!Bb5XFz&|443v{wFIS0pG%uPec`!8eJ`>(x0t7 ztscJwmstot7Y&9Qge1hEo3c=;&EwC8qR&#+Wn3U(B)Gk3`_h)~g`m8zwM4d#r}gI| z-p2+l^C{CUYPg$7tGW7nHveXRLtF*|PKfIc3cr=5IGwJCuj+y*)`Who){iP*$=-Le z2{@QbrR)&Y+P+BsttrM>^Y|NfI;ivCbZ`kN+ik@OCK)LCL8V~}6<9jRNsk3@T?hmb zKF1;|tQq1WMYa#N*{zq(vR}V^_76~T9TlGRQ_&EUr=x85gypb&E~RFWR!H5Dw%YAO z?3SEkM$p99c=JDK$gs;DTw1i=_V=^46yGttWZ=n4!;AS}Ja0g0&2Q_^QLx}agK{c9 zXJ|&iPf*$QOo_Bgq!=1&)up1>Th5fdAyu?d)d|(g=xoOqfiTJ3jnC5?2B(2Sm{dE* zi^z+7wZJzq+|&14(iv6Y=?^HP2GIkIDWm@3u4l$;l^Z)*U$d;*HlM9Bifqv&%wh08 zU-=uX)0=ek#uby)+wBSTo9^dUUr(NRIy|9-EN&!Bcwq*C_U&rbH9G|k&W9b;p5H(R zx2mlJB4^tDzN_?1PIm;wDk5(Vnwzm}PF_-jF$p3mv`AaSQ)|gOk4}@b$>y;)m3qAW zTU|zwE?kiX3&%kb%ML=9Z&m_1X3Om+8SCnNdPlVL{8qEf1Lf9*)znpJSt5~z9zhJ@ zT!()W(`S%WU}D@@THOY0#&ztfy-D14d#oW3B-$br=Bi0m>o}i!YZ`eu4>iwu;!1il znyJ^slBMh$?rT8mz9jg4J-jH(HP{+9m>r&Nw%lN&cU6Tsd(644MiUTl&ti2e{GFtz zf5>vR`#*SsFLv~%?WuarSA`?B*Y#R9p#-V?dbbng{Rw<4IiBoSG4bDDdfP}$VqKbD z?W-oQ9!FpmB4ghUe3e6i6nBcR7TX4BKSn||&c|NFX5D4x`RdvB_9gd$JkK!i(8oG$ zLeXa3Q+S)gF$$r0&wXj(#`OFa4{G6kLDwnz6wNy&TYc4Y*&;5tR;LYlm zatTU7n-Taolt1OgxkwB_X3mXUSh7(%p+7BN>Fp0a>D>e3{Z1bEjVhOUNnpCwS&&Ui z=rp2`OY;5UyDOVPkM?WYeA$yFDk2{th_3u?FGm|z{?x*cvim!-(|P&FAd9cg$snJ_ zZ5|+;x*sj{nic^yM}Z-3(Ukf}q8WdbQ5Q?E<+a4Iy+Oi}gQk}sdC@!N|DdgQiHdFP zYGF5&XRHuf@O6`F+kG2OKSpu)HyzQRB8LwmWezgC1~3hiQ{S^Ltf=C`-PYlTV7F@8 z>}-hb$zJ+ogBdc&p}~xvH5#@sxk5DzA9}UNcW(q;U}xMAho`huW{{v4na-TC3HwxY z+J}v^f`?Jy>rU_m7g(>9Mf;W1@LaT-W(*0|4Ft|*fbjPF+Z4E_lu(fC)v;7I_Ibz^1?&El}qq+Cg+Yu z+ub(Jr$KuO$^!Uz z?)!MzkN|97`pnd|4T77j2n)Y1&r_x#o(L{h}6yq8U&!ayOT zh@U#G0W*6%(>S71Cw*139VdHYs}g!lj@IK+!RzLezN$8i);lRc`P|}FmEtYb&dscPjvPajr_%63rP`85{xHS#>p=0{Fe&ddXw{GJ z*!;#%En1({7OZ|{wT0LS;JfA}HZ2xkx?>j~259ZIHy>3MQ#^6(y(aQ~Nn~uqsO~o%iB3+20_(>|6s5Ks=9yGjUa229dNnR`~V4 zuyn^gM)}?Wg*M6$PJc_nMcvoPD{gbi-7zU@uGy2TUZjO_RjtBbCl-z4-dkY)v0Gs_;nf-Kjn{6T9pwc2+m| zZ)mZ}s@1zU4Y-Guq&n4V#K*OGZ`r>l15tG3G0UG^I(rH?N&&?0ep-&~4>pWH7xoQ& zi}xU8XiQwqQZ$s*FJh%0LU#CGHus&E$)5o@=Hc7zXqyF%Y}UMr{;;=tT;-f$VYeI& zM=#|g@bKBs_tRy<*@+_0yad*aA;0`+u>SB^=%fw_3m=L^QL>~i-(w*a9*Vi=6=x?6 z8QlI}Om*ZLf1Lv(P#-@{ECiT;IxTnPevKsfMijQN`PDIK=G@XgM%{ei!0dnO28q;S zI%bdMX;0xd9cOy6DDEq)>U~X4TG_ZUG>IgydHqsZ=(0hDVLIedyIYo8t1-uG3%L&L z1L68Q$42WFR$7x_b|`8bq`Ko*t5e%Q+T#7BowZlDX0oUsDYd4bspK5PCgXq|URSll ze=3@^gB--%1@iFz3LkE z1J8Vhk@|zT4W@scG=9wX8o;?jnmF(9Zs!Ho%x2J;e>=K(o<9)HVz({8m9dnb(Gl0- zKN;&mo(p$vka= zvxqxyZyq{Q@Y+&41Gwdi8F^H=ZqzvG^MWZL3){a_X^PkIo&o<}9mOJ@roTwAU!@gD z{nOL&u`;<1X3eW>!o?=y?ZBsiV-3)VG$mHe@^7LIdv`hIS#VSK=G!Y@gPX}Zhsav4 z!KAMTUOC)U6)iY#+14(Td3Jk;0?T)uc$VQ!ul72%W<6yhM8itVoj9+fbVb4e1Ogqbqzz5e^`a+TVa@M)-MeZkyUq=x1 zs?hAxWp~&MSn`hBp!G>0mLU<%Z}xw5X3rg&d$jQ2^Vz@j0bdWIhz?->uBdgh9d>72 zDoiduIdm)xMpr+?fTs|q=NKeNt6E{vjgVb%s-C<9%zQK>#!QPJWN$9%QJ{CVCEp8A zjUQ5*h@`wqoVu(KpjFC!6nzM9btEaP&!Cxjfz_uy;l8SyJxnF|TvI+#rHy?ghW`b%AbcuOkc3R6o0M>m3p4}X>z8q5@STCSisG7lwJ z#L$->5vuD)nzM56Z??|Gz&mKMaZTah$1+h_>kKMdvA7EZMGTYMuaJ!Cq(^h7oPo!U zZgrG^%Z~t8$%{3BDr?2|^7UH+RfI&lSHyG@yH=QyhGf5B_RMWVb&?Sy32)A~vy-nr zH5ym@uJ%QJ&lY~XL>MZFOY&aCspX#1Z#pd{CP_a!y1N!VG*F0|S6rLZmVjNqesXPI zo^@kI?)u6n)u#EQqHM2A)u>#hfO)>p4r63R_ueRlb)m)R&>CHrV?*P&*s?(+_+N7FM=0U#D7_C8l_HnM;;{vRZKv zk7r%qA*YAkYn7|_qes1cLVzI3xf-Yt-F!ACbV#>RrROO8R{8a&sDvQ zrAR~BsvLj*b!FEC{cEqRKEj3%jXRegk08Y&GSEjQjgXm@RAh^lNmGS!;F_U@Hap0sLuHd9*L1J5GkS50+7TFUdY>v=aLz7jA7zUquZF?OEWDlN!e z;LV(~l;n!YLckW?i`X4GI`s>>kt_kmt?T412{CuFje%IxtN4)Hm--4hKNf8ogUK5_ zybZsM>9gvnz}PG&u8u}XuZEIE?G<`hPyS+je#Q_6_jQu@^Y;hg(J=cfBH0uNP?W)< zm83(oDN`i+GN=r-Yc_M2Qd-5|%dC2ut)Hh##g_M4ojsm$5)ve<~0NSJLgl^@Xk=z@o*wahINt7|xXFaQLD&N%Pmgkc-s{Mk0?$OrFk^X1=V@DH( zO3?ilR|ZN2T&1Ut%=JUH3+&5&p@?Z5HEydeni%2spem;%$s`9K>Cb^nU-enmk5RVb zl8ZGqGQGgeTdt>54xgRUsNSq0&j@RJkAvn@QnMfqxHS^D&wbZoc|W)!#BR9~UJ9HqtVtR#J$apAH* zTxV3h=EW`!S}v8hn|*CZV8$==It{VHzDDDUVyHq zk5}i6%asN$d+u93-1%t@X*$tiX^^nUT9~_D&G@p+2B0!#;&h}f?NEl`%snefuqp{g zK&UgWQKX@uT&O#&Ex-bjH&7bHE?$1tn;HIX=W3m8fbu0!wap++RJnkG8U%|2a8~Jz zVZ&Y(`kv4S(&$9#%$a-hcSle%sQykO3{jF0Z&RS*=(<}}qz{J;W{Bkb4`-7@N+faP z)dok{cKE(~78N8h0p%;K98Rms0~@*?8_y(C>(!wU3Fx371ui9A!Zez9obLUw_ibxY z#@9E5V{LJD7|XQZO}~C`3Ere%usm2XqJ_?5toenG+f{qA$IYNQ@zO&~=InbcG<_@> zC;nz)J`W2IzgCYmA|?g^Z3Drn5OP#UdN{*@OcdIz`XGf2?965nMAH#^Sy+GM*$I(g zEW#$b27T)7Y=(BL1H>d!JgZ(VL{d~KP&KE4YY;E zc9zvKbE{(UjtA$Ew6U z6ko}7s~TK!w6@uuJ0SCNj;?_d>dIrd59+Cmh_EPtnjW$P=oXsky5;rKaQ)w_8n&D8 zbo04VMu;=JLwS}+lQtfv?(LfMw*NU3Ijjmt%cY z`jRVk?_k@ek7D6B7Ol>XGwuGr!nS*GNFs|OTD>-`)sr5nkE58v?rK>h6T;0U=B3vw z1@gRuz%@+BdHt0E)XI(^h~u|!plO^^L{E^)J_9idj`P=0S0>GKNCQ4W;uJ-2$;T_O zi82w1EM&!5=IWF~Ww}?T1cVd9cmiegnS7G0-Sl-J)(dKpRtN`0?2 z&1wC4lbDLmnZ1KjMS|-8C@{G_F+CskgULlFq+^4%K~$nMuFV}slYMVHN%lWl`#HTL1CK#KDI_4`?*y z-zSq3KLVR3ne}Ze4Hy@%?eXztWAFD z(Gin*HRA0w{p>K!Syu+dImReS?qff->4JRUgB$FdiQug;Jt-S?3~=H_n31&X!MYyZ z?zH_TB3&`{9m=oJyB4I3!$cCCD)7;-I)GMDm=)q>!B-=0$DIpna(lY`;imqE4i#RM zw!7h8ReoOMeYw1&7QyCfNhCo8BJiD#Ev+=7`so-HMX%pHEbfays>El_(E!UOb-Uba zH5{r_H<`yY)fq$IyI$>_mPTrPSAIz~BI?{}gv#C8D51=GXJc_;3GED?0VtoH(o`P< zJ7G80^DIF&t>F86|ZlG+|q$X)smksp7g4Z!KEew`>C0vep zbq(UlqgQk{UXC1DKVUu$$yyHpF4p&2>>f4el?AUZh(wK!T^S;{7>L}d%aLCnR8I5p zG66l`Su%+jiysj)?dMRgL#%1fX&QWUt@O(npb8A1tsZEy9FnHIH}rR{ZXE`n>pQo!E!0(NQv&%6drk?iOpIkBSb3bRGFIssH7I@*rnOK$y$^E7{4 zEY{5m3nI7fmTDN>??{?Pe&0lI9;$7G+zNIW8+ZlkDV*2_(p>)_ED@i7xA-iqTQz}a z2N1haZ<&^))D;0rwBousa#lR`JYE;71Q{9F+r6hAXuXW8AA6RS(UyhKM|*tHwmE?M zDF|GEaCFnw9>c^kHBxeoz|CuGagKq(s_mc;%7smD1+2c_STG2OPIdkszdZB)Chj6% zJ;(aXfvfk0o|UIJyyv4GYYH4s7h&QdZ@h}+7y({~J03ztld-ihFD-yz!V#hHN zmdP7~afydTWDzT~u}Td`tc-`vjff5|@Q6rOyc0*21WPhmTu*GcU*}O_e1|i)&XV0i zb>UWrov-VhiEN*qjbg#=d;G#WA>E4k#*M9eTwlZEfv#DX0pwjFftjwV3lD!_sXa@u z$xC2R&7x1J4p_7&s3!~PjZwfnuw}jzq3$BOt|RSHk0&wt1Bec|iJ-TXH7CH8u%c;Ko#xch4@ zb_>Ees7pBE-FE^!8Ue&d#um+XLjS9*XVBQcIJ8yN z+D@R=U7MlUsuH_fJU9&d$nv#0+;?h7)7q5)IZmhC8_tg8WUt3TeI6;L*#3o}JS!XI zK2?Ctq(s*y?T$}-ww1Q|S7`GMbLC$41}Wxiz4&CoOh4Q}(3l?1Dy(UtEqy+tQBU3- z>)>{dgqPB$SeX%v5Q3*wX6-9O(nTD~z|uab&;XoeUNlsYdtxz9ho6~uU_QjsV&9{( zx%AE(qHN8NGPcOo4L0`iQbg|h=cfUUCz0RN(Pek-v-+=u3;W$sl?WdIq;#oDXh-(? z?1_?OD@!A6(fzbBQop68zvnOug;TS6^FSbr0$738Le&KgD1J+sZ?a3C+q=GVPZa39 zPdH)Lb`-Gc?p8!#&YYJi6UqH{0ANomi+Pyc37)5|7YkF4*(0vbYU=3{A3DolcFJd* zKWrp-m7LBSUj`N4o$LxAH+b7$JVQQzwTRQG1vVplmBl^NRUqW_n6+3EzjVkGsvN;; zXh?VFM(8W1pD#MS7m7`(S$P+;$N&#_PfUcjSJ~Sca!V0c>!cWE`$Z0nOdZk>W3hSd zMT;`Ph^#Q6YD1!{gIam$6>MYGty0!$#Eo`5b7whyB}u+g05CYs+Bmc8J@2(vuJ%LC z`TWhEm%Sa^ojb9;r#z2y{u@m&-r`FtiFO?gPm-FHV^fk9lOs{IB(d>lnbQ~4fKmZp z&-o)nu;53&2i?+r(g<(QxV`HrY_7)O)jdS<5@P$}8L}u@pW%i710JB%Co*rYcd0XV zZ5ix4Ix$Lr8E2S=8Q{vS!mT2eGp`X_lK_pQ#)RZsbVL!?kt%WEF=YrO$^W_$~6Zgl&RBH#q@PIcta>8zO)H z;vcRKxN1Clzx%_&ljH=ttE}?ZT+_Y?_g32bQ{)8^}I629{>4wG9Z*x_4NB%ky#F*4UCXTpDv6m zOEN%1j4qWz6JU{dLi#kI=aX!ORdnVEQL(UT;88Wx));emF>UH_S3@esbLowgOLvTN zX5;4=2kjb5Z4wdUhqrt7D0UKhyalyH$eWedPH&o3IE&0jA9HL|yFf0wR`)h#hM~PF zL~2imM~WNK7YhR&{@GhKW~ULzUTZ`0oIYaR9VgS$F7OlBT>bgHKHgC4zR!r#*_9N0 znZpq2RMh8U4~)p}6Vjpk<_gqdM2&Pvj?15b6l?B93?JpMtN(g&0PKv(^VgnyAezlh zxL#5;Y`5nB$23*A0v84XQ3Z(*OJwS#jkrt`sCZ z_ta1T$AvdTZtGEx_j%Jt0hSl_n_SM>`PGVv=c-jBIbH? z-}l>@n53t?SB!S%*AAP(mQg_a=+w6XuC+iCDIN2%(c+@3P#*edFyyClr^V z!xE0TQYr=hDfq*UjjxENpbec<*Sv?H=mCme>FMycyj|#3!ItJ_Ci$@j(1A)QoS!S+ zMxRZ8>g*EiFbR6(Et(&<#MXDQ=SjiqfLlR4CC6N1`ngSg8JCPCN$;n!1}rMi26jnQ z^~N*KOtsht(P~$7ws*|%W|w=?#~7$6i9pfCURBjACt)`Ewm-B}?F|IN@(_o=3OPHA z?_IFN$%okwAJLR)&}xX!b9l;!tMm!c{z_RN*uQt(R2}fMa3y(bsV0*?F*dINRSs$o zgSZ+;jkgzRE#0HC=AFD`wmC91?60HbJygNp1a8a$N=0~-DpryYbl>~wt;njl@ZmXq zN8|||uAVxhxX?EXdTsp547}TEI#FQ-&4m68tc2OKuXV$U!~sBhV}oDK_!ShGe(#u> zu||Dbox!8oeZ3!SvrjcYlsXd6AR7UpYaO0`B_CEMtgsR)asrPB;9-6CrQXDb=K=h9jhh|=eM8JE#y+~zP&i$PY~ll|7GUhhIRc$J5p7B(<+(3;z^X@{)@+GSdB)0Tt*$>| z>HyAJ=`QIG)?``R0QM8)gjNI}qggW-7`6==0ZQ$l9Gh1I*RsPlAwD!NEZ*aj4=M=h z@%C)!N4lJ!3uAg8h#s%QJDAdZO%L1Eu9VQu`WKik6HV$1p;XMp_~7>}t@rL4Cv#M% z72T7clxgRfAm6yKsMVr#gPm5wG^-;lT!kmNuhj`|Zhy|7Yqhu#ADH@uNm}4;DpQL- zAX$df=kAuGl_q4oZlyDl>bX0V(cYSXl(wOAR*Ge0%m*XWN*^n3F&fP z+JHHOZOvknoTadEn$@fet=oJ__?%|>cqSq8&B6ELCxk!!A}^4HS)O|VP2UmIl}lZ3 zybB!$B+VbDn30#x*g)qxH7LAtGL6lq-0Yd^?s01^n_b0zmH*fV!JCt5Vlrf0ekwR2 z=Q(ZfHq~A4FR5yx|BUaxQok<3)2>xZxRSKWlEmXc|DQ3cQ9R5M6w%#h7qlX&-uqZW_;P2)dY0%t&t( z#>vUxpq&x~t#hfIJc>qds76N67+Z7a71`0K{z7;oe*oFC4xZ{+k}3|I=w^8}j}rQy zhd45cTR|EGHy43S5qGqZU(Cw-(|NGPMqHg;H!1%eFO@g|YNJ{MxcE35pa`0bTZy+> z%2`mEZMe@l@L^saV$ztCnQze|F2`I2&oRjp*_W8vukY#s23#k&EmATQ>)C*cU2Dm0 z^KXKR}Li`09xGcG8kuF_8kWuxQX!9Q~B+~_kht(|Eq{ic$2 zjOi=e5m-z|*T&B7Yi?E)nW;p3M|g;b2^P3jeS$|Xum!pbsdk_7*4GdpC;tdJ9c*nW zRmw8{)X?-6Vuu!g*+OKjM(J&UQ>oeMtSLDcUpFiKoWZ8G3*B4vx{Q#lQnsZ3i|=BY zu{M(5?po_$V(3STP5*;b@3`5u-->JHD$ExnpIgk0Us1K?8ZAI9B|8$^e4F03oz0#+ zATpb(d$XgmWmsN-#UzY5|5(|bVK~941*vP!8->!j^gpSUuBlAyMOsZaeeOn;BSBVDg)EN_m4+1u_zxs8 z8mGC}UpNYb+Fh*5_fhQsFiC*=@Y2da>v5x5Q#YwGPjj=I=>UKvU>{{gE3(2rTH}4H zLtmoTGaOUdK!^0|I^{+j%68nU?OaQRz&KiGu4uuFS_+9MLLNHM@56Hjn0$7%5EoNc ze6^3eEH}Zj$^8{X;6;12sJ|m`xp(7(2jc`buHIkZ<*Ym>8|c-E^k44Y7xmgu;ae$P z76|Oo_e53RRPc#;+SezOh0Vg=F{#+^OR;AW!9iK%cjA=+)gyP@DK7U#UcDUJ>ypfL{C9 z(6P7&Z*KBB31M4^$ReY^8`3b9di<&ijQ#&)=s#RbIrSI+$= z@7>R9YfNtYBqu7s?IXU_cxF924(U77QiGpzREQ1RbuGM`?YUmyHU{uCO|LbY&P$cM z=6)dd_IPx``m@CE?}ws0TG+{KwuI_zK~)a<$Dd!2{3w|Csyi)NkIy&PZ??z+)os@3E+ z25)zKe59P1tsH)-x@F%)sO9eG+?na%iVzqG#iVv8jkBI_MzP2NG5w6XUq z@;&TA(AZH3v{71+>l4TO!%gB#!9P$CDVyw-iCse*DM(U3A@wy#<-U9f+G?%$@;{@?Q(^(0n|i#CY$yDDL?c*`@aedXJqI#oHt9fUSZ!J_)(ZWA*ck+ z&CCC{{C8!8ruF~#9sDb><^TWnUrYUeYX@}jaW<(8QpCp%2mAUUr~0l+#`Meo0D_AC A761SM diff --git a/scripts/perf_benchmark/example_report/panda_madrona_rasterizer_plot.png b/scripts/perf_benchmark/example_report/panda_madrona_rasterizer_plot.png deleted file mode 100644 index 792e6be6672ae85e54c3d3dc19de8b64ca313c46..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 72824 zcmeFa2UL~kx-E<`i96OE8?lnaWkn#04G>5)i7pUOL=b5iGF0i`M^Qba(S^s@dr7uoxqd+t8%ALEWY&bVQupyFEJ_kQpDJoA}z zKF|7bv+jloV`q+)k&&4o-1xP=jLewFGBO`L`_qT`ogG>DMubk7&=O15p>Cdd1y&7eaF`T2=FvhhbdtqdF_+HC^_UmSzT~8M; zUYvb?@m52Z#kej_xAzX=f9W?fzpAPUo4Imd__k|?jis>`8vU<-nWkEr)>9R<=ZwmN zAcu6fZ_msQytMvA%;R>^esjFgyT;;$3MPqp7Lxu_Q?I@{`4j+&V4`fZxI+vQcS_X{9t4VyNsm8-rt>xC%RaJ*y353<@p2a_Xbu8HZ zZF1j0wImeRI-qu1-MS$7$<=N0#g}!o^DnM6+J4x4(bVOxZ=XGhHg-CGs%MX~eZ?(- z@g<-A7K3KUt9SK|Z@7(jXbZ7VX-O1{vwauJR>q{fy>sZzlf^4-OY*K>i@9i?cBC%E zwJPdRRf&m1$mvxTiwf6vZc2RSn9H}yk`sAoX=yEYtbLRhs-Dt&yZ2gbx~eelz{`8V zE)_O5<#Co*jbcNcONAW-9c#~R zunKW55q70*{aS2sq+S>=(;=

$i< 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 diff --git a/scripts/perf_benchmark/example_report/panda_madrona_raytracer_plot.png b/scripts/perf_benchmark/example_report/panda_madrona_raytracer_plot.png deleted file mode 100644 index f044f5d8a87d26a039761867dc4751b766e4f4aa..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 69796 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%0D@Shp# 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 W*{mA 0 or \ - elem.get("contype", 0) > 0: - remove_elems.append(elem) - continue - - body = ET.Element("body") - body_idx = link_geoms.get(cur_link_name, 0) - link_geoms[cur_link_name] = body_idx + 1 - body.set("name", f"{cur_link_name}_geom{body_idx}") - body.append(copy.deepcopy(elem)) - replace_elems.append((elem, body)) - else: - nex_link_name = None - if elem.tag == "body": - nex_link_name = elem.get("name", None) - # Recurse into children - wrap_geom(elem, nex_link_name if nex_link_name else cur_link_name) - - for elem, body in replace_elems: - idx = list(cur).index(elem) - cur.remove(elem) - cur.insert(idx, body) - for elem in remove_elems: - cur.remove(elem) - - wrap_geom(root) - tree.write(output_file, encoding="utf-8", xml_declaration=True) - - -def process_config_file(config_file): - """Process all robot files mentioned in a specific benchmark configuration file.""" - if not os.path.exists(config_file): - print(f"Configuration file not found: {config_file}") - return - - print(f"Processing configuration file: {config_file}") - - try: - with open(config_file, 'r') as f: - config = yaml.safe_load(f) - - if 'mjcf_list' not in config: - print(f"No mjcf_list found in {config_file}") - return - - mjcf_list = config['mjcf_list'] - print(f"Found {len(mjcf_list)} robot files in {config_file}") - - processed_count = 0 - for mjcf_path in mjcf_list: - # Construct full path using asset directory - full_mjcf_path = os.path.join(asset_dir, mjcf_path) - - if not os.path.exists(full_mjcf_path): - print(f"Warning: Robot file not found: {full_mjcf_path}") - continue - - print(f"Processing robot file: {full_mjcf_path}") - output_file = f"{os.path.splitext(full_mjcf_path)[0]}_new.xml" - process_mjcf_geoms(full_mjcf_path, output_file) - processed_count += 1 - print(f"Created processed file: {output_file}") - - print(f"Total files processed: {processed_count}") - - except Exception as e: - print(f"Error processing {config_file}: {e}") - - -def main(): - parser = argparse.ArgumentParser(description="Process MJCF robot files from a benchmark configuration file") - parser.add_argument("--file", type=str, help="Path to the benchmark configuration YAML file") - args = parser.parse_args() - - # Process robot files from the specified configuration file - process_config_file(args.file) - - -if __name__ == "__main__": - main() diff --git a/scripts/profile.py b/scripts/profile.py deleted file mode 100644 index b4df90e1..00000000 --- a/scripts/profile.py +++ /dev/null @@ -1,106 +0,0 @@ -import os -import sys -import json -import subprocess -import pandas as pd - -NUM_THREADS_PER_BLOCK = 256 -NUM_SMS = 82 -BASE_STEP = 10 -DIR_PATH = "/tmp/profile_blocks__megakernel_events" -# only take action when the change of config for certain node can contribute to at least 0.1% overall acceleration -THRESHOLD = 1000 - - -def profile_madrona(bench_cmd, block_config=range(1, 7), cache="/tmp/madcache"): - try: - # os.remove(cache) - pass - except: - pass - - for config in block_config: - profile_command = "MADRONA_MWGPU_ENABLE_PGO=1 MADRONA_MWGPU_TRACE_NAME=profile_{block}_block MADRONA_MWGPU_EXEC_CONFIG_OVERRIDE={thread},{block},{sm} MADRONA_MWGPU_KERNEL_CACHE={cache} {bench_cmd}".format( - thread=NUM_THREADS_PER_BLOCK, - block=config, - sm=NUM_SMS, - cache=cache, - bench_cmd=bench_cmd, - ) - subprocess.run(profile_command, shell=True, text=True) - - -def parse_traces(block_config=range(1, 7)): - from parse_device_tracing import parse_device_logs, step_analysis - - tabular_data = [ - pd.DataFrame( - { - "nodeID": [], - "funcID": [], - "duration (ns)": [], - "invocations": [], - "percentage (%)": [], - "SM utilization": [], - } - ) - for _ in block_config - ] - - isExist = os.path.exists(DIR_PATH) - if not isExist: - os.mkdir(DIR_PATH) - for i in block_config: - path_to_trace = "/tmp/profile_{}_block_madrona_device_tracing.bin".format(i) - - with open(path_to_trace, "rb") as f: - events = bytearray(f.read()) - assert len(events) % 40 == 0 - print("{} events were logged in total".format(len(events) // 40)) - log_steps = parse_device_logs(events) - assert len(log_steps) > BASE_STEP - - with pd.ExcelWriter(DIR_PATH + "/block_{}_metrics.xlsx".format(i)) as writer: - step_analysis( - log_steps[BASE_STEP], - DIR_PATH + "/block_{}.png".format(i), - tabular_data[i - 1], - ).to_excel(writer, index=False) - - -def generate_json(block_config=range(1, 7)): - tabular_data = [pd.read_excel(DIR_PATH + "/block_{}_metrics.xlsx".format(i)) for i in block_config] - tabular_data = ["paddding"] + tabular_data - - overall_durations = {i: sum(tabular_data[i]["duration (ns)"]) for i in block_config} - base_config, base_duration = min(overall_durations.items(), key=lambda x: x[1]) - - duration_deduction = 0 - split_nodes = {} - for i, node in enumerate(tabular_data[base_config]["nodeID"]): - min_duration = tabular_data[base_config]["duration (ns)"][i] - for b in block_config: - if min_duration - tabular_data[b]["duration (ns)"][i] > base_duration / THRESHOLD: - duration_deduction += min_duration - tabular_data[b]["duration (ns)"][i] - min_duration = tabular_data[b]["duration (ns)"][i] - split_nodes[node] = b - - print( - "with default {} block per SM config and following exceptions:".format(base_config), - split_nodes, - ) - print("the estimated acceleration will be {:.3f}%".format(duration_deduction / base_duration * 100)) - - with open(DIR_PATH + "/node_blocks.json", "w") as f: - json.dump(split_nodes, f) - - -if len(sys.argv) < 2: - print(f"{sys.argv[0]} benchmark command and args", file=sys.stderr) - sys.exit(1) - -bench_cmd = " ".join(sys.argv[1:]) - -profile_madrona(bench_cmd) -parse_traces() -generate_json() diff --git a/src/render/font.ttf b/src/render/font.ttf deleted file mode 100644 index d017d72d471ab03ef269bd82922a4e47214fd50f..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 340712 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

%)AR1c{UVbdxnDYUk#q*R${F0AgAK-_ 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%0A`HvwD9vlF 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%0APnqJC?5Ez%Qx#typm((thq5EgWZ{77rK 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

yw41!fZc*Dz-}S> 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 AGXMYp diff --git a/src/render/shaders/batch_draw_depth.hlsl b/src/render/shaders/batch_draw_depth.hlsl deleted file mode 100644 index 83644e49..00000000 --- a/src/render/shaders/batch_draw_depth.hlsl +++ /dev/null @@ -1,102 +0,0 @@ -#include "shader_utils.hlsl" - -[[vk::push_constant]] -BatchDrawPushConst pushConst; - -// Instances and views -[[vk::binding(0, 0)]] -StructuredBuffer viewDataBuffer; - -[[vk::binding(1, 0)]] -StructuredBuffer engineInstanceBuffer; - -[[vk::binding(2, 0)]] -StructuredBuffer instanceOffsets; - -// Draw information -[[vk::binding(0, 1)]] -RWStructuredBuffer drawCount; - -[[vk::binding(1, 1)]] -RWStructuredBuffer drawCommandBuffer; - -[[vk::binding(2, 1)]] -RWStructuredBuffer drawDataBuffer; - -// Asset descriptor bindings -[[vk::binding(0, 2)]] -StructuredBuffer vertexDataBuffer; - -[[vk::binding(1, 2)]] -StructuredBuffer meshDataBuffer; - -[[vk::binding(2, 2)]] -StructuredBuffer materialBuffer; - -struct V2F { - [[vk::location(0)]] float3 vsCoord : TEXCOORD0; -}; - -[shader("vertex")] -float4 vert(in uint vid : SV_VertexID, - in uint draw_id : SV_InstanceID, - out V2F v2f) : SV_Position -{ - DrawDataBR draw_data = drawDataBuffer[draw_id + pushConst.drawDataOffset]; - - Vertex vert = unpackVertex(vertexDataBuffer[vid]); - uint instance_id = draw_data.instanceID; - - PerspectiveCameraData view_data = unpackViewData(viewDataBuffer[draw_data.viewID]); - EngineInstanceData instance_data = unpackEngineInstanceData(engineInstanceBuffer[instance_id]); - - float3 to_view_translation; - float4 to_view_rotation; - computeCompositeTransform(instance_data.position, instance_data.rotation, - view_data.pos, view_data.rot, - to_view_translation, to_view_rotation); - - float3 view_pos = - rotateVec(to_view_rotation, instance_data.scale * vert.position) + - to_view_translation; - - float depth = length(view_pos); - float z_far = view_data.zFar; - float z_near = view_data.zNear; - - float clip_z = z_far / (z_far - z_near) * view_pos.y + - (z_far * z_near) / (z_near - z_far); - float4 clip_pos = projectToClip( - view_data, - view_pos, - clip_z); - -#if 1 - uint something = min(0, instanceOffsets[0]) + - min(0, drawCount[0]) + - min(0, drawCommandBuffer[0].vertexOffset) + - min(0, int(ceil(meshDataBuffer[0].vertexOffset))); - - // v2f.meshID = draw_data.meshID; -#endif - - clip_pos.x += min(0.0, abs(float(draw_data.meshID))) + - min(0.0, abs(float(draw_data.instanceID))) + - something; - - v2f.vsCoord = view_pos; - - return clip_pos; -} - -struct PixelOutput { - float depthOut : SV_Target0; -}; - -[shader("pixel")] -PixelOutput frag(in V2F v2f, in uint prim_id : SV_PrimitiveID) -{ - PixelOutput output; - output.depthOut = length(v2f.vsCoord) + min(0.0, abs(materialBuffer[0].color.x)); - return output; -} diff --git a/src/render/shaders/blur.hlsl b/src/render/shaders/blur.hlsl deleted file mode 100644 index 680758f3..00000000 --- a/src/render/shaders/blur.hlsl +++ /dev/null @@ -1,58 +0,0 @@ -#include "shader_utils.hlsl" - -[[vk::push_constant]] -BlurPushConst pushConst; - -[[vk::binding(0, 0)]] -RWTexture2D attachment; - -[[vk::binding(1, 0)]] -RWTexture2D intermediate; - -#define WEIGHT_COUNT 5 - -[numThreads(32, 32, 1)] -[shader("compute")] -void blur(uint3 idx : SV_DispatchThreadID) -{ - uint2 targetDim; - attachment.GetDimensions(targetDim.x, targetDim.y); - - if (idx.x < targetDim.x && idx.y < targetDim.y) { - uint2 targetPixel = idx.xy; - - const float weights[WEIGHT_COUNT] = { -#if 0 - 20.0 / 64.0, - 15.0 / 64.0, - 6.0 / 64.0, - 1.0 / 64.0 -#endif - 0.227027, 0.1945946, 0.1216216, 0.054054, 0.016216 - // 1.0 - }; - - if (pushConst.isVertical) { - // Read from intermediate, write to attachment - float2 output = intermediate[targetPixel].rg * weights[0]; - - for (int i = 1; i < WEIGHT_COUNT; ++i) { - output += intermediate[targetPixel + uint2(0, i)].rg * weights[i]; - output += intermediate[targetPixel - uint2(0, i)].rg * weights[i]; - } - - attachment[targetPixel] = output; - } - else { - // Read from attachment, write to intermediate - float2 output = attachment[targetPixel].rg * weights[0]; - - for (int i = 1; i < WEIGHT_COUNT; ++i) { - output += attachment[targetPixel + uint2(i, 0)].rg * weights[i]; - output += attachment[targetPixel - uint2(i, 0)].rg * weights[i]; - } - - intermediate[targetPixel] = output; - } - } -} diff --git a/src/render/shaders/draw_deferred_depth.hlsl b/src/render/shaders/draw_deferred_depth.hlsl deleted file mode 100644 index d9d42e05..00000000 --- a/src/render/shaders/draw_deferred_depth.hlsl +++ /dev/null @@ -1,160 +0,0 @@ -#include "shader_utils.hlsl" - -// GBuffer descriptor bindings - -[[vk::push_constant]] -DeferredLightingPushConstBR pushConst; - -// This is an array of all the textures -[[vk::binding(0, 0)]] -RWTexture2DArray rgbInBuffer[]; - -[[vk::binding(1, 0)]] -RWStructuredBuffer rgbOutputBuffer; - -[[vk::binding(2, 0)]] -RWStructuredBuffer depthOutputBuffer; - -[[vk::binding(3, 0)]] -Texture2D depthInBuffer[]; - -[[vk::binding(4, 0)]] -SamplerState linearSampler; - -[[vk::binding(0, 1)]] -StructuredBuffer indexBuffer; - -// Instances and views -[[vk::binding(0, 2)]] -StructuredBuffer viewDataBuffer; - -[[vk::binding(1, 2)]] -StructuredBuffer engineInstanceBuffer; - -[[vk::binding(2, 2)]] -StructuredBuffer instanceOffsets; - - -// Lighting -[[vk::binding(0, 3)]] -StructuredBuffer lights; - -[[vk::binding(1, 3)]] -Texture2D transmittanceLUT; - -[[vk::binding(2, 3)]] -Texture2D irradianceLUT; - -[[vk::binding(3, 3)]] -Texture3D scatteringLUT; - -[[vk::binding(4, 3)]] -StructuredBuffer skyBuffer; - - -#include "lighting.h" - -uint zeroDummy() -{ - uint zero_dummy = min(asuint(viewDataBuffer[0].data[2].w), 0) + - min(asuint(engineInstanceBuffer[0].data[0].x), 0) + - min(indexBuffer[0], 0) + - min(instanceOffsets[0], 0) + - min(0.0, abs(transmittanceLUT.SampleLevel( - linearSampler, float2(0.0, 0.0f), 0).x)) + - min(0.0, abs(irradianceLUT.SampleLevel( - linearSampler, float2(0.0, 0.0f), 0).x)) + - min(0.0, abs(scatteringLUT.SampleLevel( - linearSampler, float3(0.0, 0.0f, 0.0f), 0).x)) + - min(0.0, abs(skyBuffer[0].solarIrradiance.x)) + - min(0.0, abs(float(rgbInBuffer[0][uint3(0,0,0)].x))) + - min(0.0, abs(viewDataBuffer[0].data[0].x)) + - min(0.0, abs(engineInstanceBuffer[0].data[0].x)) + - min(0.0, abs(float(indexBuffer[0]))) + - min(0.0, abs(depthInBuffer[0].SampleLevel(linearSampler, float2(0,0), 0).x)); - - - return zero_dummy; -} - -float linearToSRGB(float v) -{ - if (v <= 0.00031308f) { - return 12.92f * v; - } else { - return 1.055f*pow(v,(1.f / 2.4f)) - 0.055f; - } -} - -uint32_t linearToSRGB8(float3 rgb) -{ - float3 srgb = float3( - linearToSRGB(rgb.x), - linearToSRGB(rgb.y), - linearToSRGB(rgb.z)); - - uint3 quant = (uint3)(255 * clamp(srgb, 0.f, 1.f)); - - return quant.r | (quant.g << 8) | (quant.b << 16) | ((uint32_t)255 << 24); -} - -// idx.x is the x coordinate of the image -// idx.y is the y coordinate of the image -// idx.z is the global view index -[numThreads(32, 32, 1)] -[shader("compute")] -void lighting(uint3 idx : SV_DispatchThreadID) -{ - uint view_idx = idx.z; - - uint num_views_per_image = pushConst.maxImagesXPerTarget * - pushConst.maxImagesYPerTarget; - - // Figure out which image to render to - uint target_idx = view_idx / num_views_per_image; - - // View index within that target - uint target_view_idx = view_idx % num_views_per_image; - - uint target_view_idx_x = target_view_idx % - pushConst.maxImagesXPerTarget; - uint target_view_idx_y = target_view_idx / - pushConst.maxImagesXPerTarget; - - float x_pixel_offset = target_view_idx_x * pushConst.viewWidth; - float y_pixel_offset = target_view_idx_y * pushConst.viewHeight; - - if (idx.x >= pushConst.viewWidth || idx.y >= pushConst.viewHeight) { - return; - } - - uint3 vbuffer_pixel = uint3(idx.x, idx.y, 0); - - float2 vbuffer_pixel_clip = - float2(float(vbuffer_pixel.x) + 0.5f, float(vbuffer_pixel.y) + 0.5f) / - float2(pushConst.viewWidth, pushConst.viewHeight); - - vbuffer_pixel_clip = vbuffer_pixel_clip * 2.0f - float2(1.0f, 1.0f); - vbuffer_pixel_clip.y *= -1.0; - - uint2 sample_uv_u32 = vbuffer_pixel.xy + uint2(x_pixel_offset, y_pixel_offset); - - float2 total_res = float2(pushConst.viewWidth * pushConst.maxImagesXPerTarget, pushConst.viewHeight * pushConst.maxImagesYPerTarget); - - float2 sample_uv = float2(sample_uv_u32) / total_res; - sample_uv.y = 1.0 - sample_uv.y; - - float depth = rgbInBuffer[target_idx][vbuffer_pixel + - uint3(x_pixel_offset, y_pixel_offset, 0)]; - - float3 out_color = float3(depth, depth, depth); - - out_color.x += zeroDummy(); - - uint32_t out_pixel_idx = - view_idx * pushConst.viewWidth * pushConst.viewHeight + - idx.y * pushConst.viewWidth + idx.x; - - rgbOutputBuffer[out_pixel_idx] = linearToSRGB8(float3(0 + zeroDummy(), 0, 0)); - depthOutputBuffer[out_pixel_idx] = depth; -} diff --git a/src/render/shaders/grid_draw.hlsl b/src/render/shaders/grid_draw.hlsl deleted file mode 100644 index 32e6a3e3..00000000 --- a/src/render/shaders/grid_draw.hlsl +++ /dev/null @@ -1,122 +0,0 @@ -#include "shader_utils.hlsl" - -[[vk::push_constant]] -GridDrawPushConst pushConst; - -[[vk::binding(0, 0)]] -RWTexture2D gridOutput; - -[[vk::binding(1, 0)]] -StructuredBuffer batchRenderRGBOut; - -[[vk::binding(2, 0)]] -StructuredBuffer batchRenderDepthOut; - -float vizDepth(float v) -{ - return log(v + 0.9f) / log(10000.f); -} - -float srgbToLinear(float srgb) -{ - if (srgb <= 0.04045f) { - return srgb / 12.92f; - } - - return pow((srgb + 0.055f) / 1.055f, 2.4f); -} - -float4 rgb8ToFloat(uint r, uint g, uint b) -{ - return float4( - srgbToLinear((float)r / 255.f), - srgbToLinear((float)g / 255.f), - srgbToLinear((float)b / 255.f), - 1.f); -} - -float4 fetchBatchRenderPixel(uint3 pixel_idx) -{ - pixel_idx.x = clamp(pixel_idx.x, 0, pushConst.viewWidth - 1); - pixel_idx.y = clamp(pixel_idx.y, 0, pushConst.viewHeight - 1); - - uint linear_pixel_idx = - pixel_idx.z * pushConst.viewHeight * pushConst.viewWidth + - pixel_idx.y * pushConst.viewWidth + pixel_idx.x; - - if (pushConst.showDepth == 1) { - float depth = vizDepth(batchRenderDepthOut[linear_pixel_idx]); - return float4(float3(depth, depth, depth), 1); - } else { - uint packed = batchRenderRGBOut[linear_pixel_idx]; - - uint r = packed & 0xFF; - uint g = (packed >> 8) & 0xFF; - uint b = (packed >> 16) & 0xFF; - - return rgb8ToFloat(r, g, b); - } -} - -float4 sampleBatchRenderOutput(float2 uv, uint view_idx) -{ - float2 coords = - uv * float2(pushConst.viewWidth, pushConst.viewHeight); - - float2 base = floor(coords); - float2 diff = coords - base; - uint2 c00 = (uint2)base; - uint2 c01 = c00 + uint2(1, 0); - uint2 c10 = c00 + uint2(0, 1); - uint2 c11 = c00 + uint2(1, 1); - - float4 p00 = fetchBatchRenderPixel(uint3(c00, view_idx)); - float4 p01 = fetchBatchRenderPixel(uint3(c01, view_idx)); - float4 p10 = fetchBatchRenderPixel(uint3(c10, view_idx)); - float4 p11 = fetchBatchRenderPixel(uint3(c11, view_idx)); - - float4 a = p00 + diff.x * (p01 - p00); - float4 b = p10 + diff.x * (p11 - p10); - - return a + diff.y * (b - a); -} - -[numThreads(32, 32, 1)] -[shader("compute")] -void gridDraw(uint3 idx : SV_DispatchThreadID) -{ - uint2 target_dim; - gridOutput.GetDimensions(target_dim.x, target_dim.y); - - if (idx.x >= target_dim.x || idx.y >= target_dim.y) { - return; - } - - // Get the view index that this pixel is going to sample from - float global_pixel_x = pushConst.offsetX + float(idx.x); - float global_pixel_y = pushConst.offsetY + float(idx.y); - - float ratio_x = global_pixel_x / float(pushConst.gridViewSize); - float ratio_y = global_pixel_y / float(pushConst.gridViewSize); - - int view_idx_x = floor(ratio_x); - int view_idx_y = floor(ratio_y); - - float uv_x = ratio_x - float(view_idx_x); - float uv_y = ratio_y - float(view_idx_y); - - if (view_idx_x < 0 || view_idx_y < 0 || view_idx_x >= pushConst.gridWidth) { - gridOutput[idx.xy] = float4(0, 0, 0, 0); - return; - } - - // Get linear view index - int view_idx = view_idx_x + view_idx_y * pushConst.gridWidth; - - if (view_idx >= pushConst.numViews) { - gridOutput[idx.xy] = float4(0, 0, 0, 0); - return; - } - - gridOutput[idx.xy] = sampleBatchRenderOutput(float2(uv_x, uv_y), view_idx); -} diff --git a/src/render/shaders/shadow_gen.hlsl b/src/render/shaders/shadow_gen.hlsl deleted file mode 100644 index 0f9688f7..00000000 --- a/src/render/shaders/shadow_gen.hlsl +++ /dev/null @@ -1,164 +0,0 @@ -#include "shader_utils.hlsl" - -[[vk::push_constant]] -ShadowGenPushConst pushConst; - -[[vk::binding(0, 0)]] -RWStructuredBuffer shadowViewDataBuffer; - -[[vk::binding(1, 0)]] -StructuredBuffer flycamBuffer; - -[[vk::binding(2, 0)]] -StructuredBuffer lights; - -[[vk::binding(3, 0)]] -StructuredBuffer viewDataBuffer; - -[[vk::binding(4, 0)]] -StructuredBuffer viewOffsetsBuffer; - -float4 invQuat(float4 rot) -{ - return float4(-rot.x, -rot.y, -rot.z, rot.w); -} - -PerspectiveCameraData getCameraData() -{ - if (pushConst.viewIdx == 0) { - return unpackViewData(flycamBuffer[0]); - } else { - int view_idx = (pushConst.viewIdx - 1) + viewOffsetsBuffer[pushConst.worldIdx]; - return unpackViewData(viewDataBuffer[view_idx]); - } -} - -[numThreads(32, 1, 1)] -[shader("compute")] -void shadowGen(uint3 idx : SV_DispatchThreadID) -{ - /* Assume that the sun is from lights[0] */ - if (idx.x != 0) - return; - - PerspectiveCameraData unpackedView = getCameraData(); - - float3 cam_pos = unpackedView.pos; - float4 cam_rot = invQuat(unpackedView.rot); - - float3 cam_fwd = rotateVec(cam_rot, float3(0.0f, 1.0f, 0.0f)); - float3 cam_up = rotateVec(cam_rot, float3(0.0f, 0.0f, 1.0f)); - float3 cam_right = rotateVec(cam_rot, float3(1.0f, 0.0f, 0.0f)); - - // Construct orthonormal basis - ShaderLightData light = unpackLightData(lights[0]); - float3 light_fwd = normalize(light.direction.xyz); - float3 light_up = (light_fwd.x < 0.9999f) ? - normalize(cross(float3(1.f, 0.f, 0.f), light_fwd)) : - float3(0.f, 0.f, 1.f); - float3 light_right = cross(light_fwd, light_up); - - // Note that we use the basis vectors as the *rows* of the to_light - // transform matrix, because we want the inverse of the light to world - // matrix (which is just the transpose for rotation matrices). - float3x3 to_light = float3x3( - light_right.x, light_right.y, light_right.z, - light_fwd.x, light_fwd.y, light_fwd.z, - light_up.x, light_up.y, light_up.z - ); - - float far_width, near_width, far_height, near_height; - - float tan_half_fov = -1.0f / unpackedView.yScale; - float aspect = -unpackedView.yScale / unpackedView.xScale; - float near = 1.0f; - float far = 80.0f; - - far_height = 2.0f * far * tan_half_fov; - near_height = 2.0f * near * tan_half_fov; - far_width = far_height * aspect; - near_width = near_height * aspect; - - float3 center_near = cam_pos + cam_fwd * near; - float3 center_far = cam_pos + cam_fwd * far; - - float far_width_half = far_width / 2.0f; - float near_width_half = near_width / 2.0f; - float far_height_half = far_height / 2.0f; - float near_height_half = near_height / 2.0f; - - // f = far, n = near, l = left, r = right, t = top, b = bottom - enum OrthoCorner { - flt, flb, - frt, frb, - nlt, nlb, - nrt, nrb - }; - - float3 ls_corners[8]; - -#if 0 - ls_corners[flt] = mul(view, float4(cam_pos + ws_direction * far - right_view_ax * far_width_half + up_view_ax * far_height_half, 1.0f)); - ls_corners[flb] = mul(view, float4(ws_position + ws_direction * far - right_view_ax * far_width_half - up_view_ax * far_height_half, 1.0f)); - ls_corners[frt] = mul(view, float4(ws_position + ws_direction * far + right_view_ax * far_width_half + up_view_ax * far_height_half, 1.0f)); - ls_corners[frb] = mul(view, float4(ws_position + ws_direction * far + right_view_ax * far_width_half - up_view_ax * far_height_half, 1.0f)); - ls_corners[nlt] = mul(view, float4(ws_position + ws_direction * near - right_view_ax * near_width_half + up_view_ax * near_height_half, 1.0f)); - ls_corners[nlb] = mul(view, float4(ws_position + ws_direction * near - right_view_ax * near_width_half - up_view_ax * near_height_half, 1.0f)); - ls_corners[nrt] = mul(view, float4(ws_position + ws_direction * near + right_view_ax * near_width_half + up_view_ax * near_height_half, 1.0f)); - ls_corners[nrb] = mul(view, float4(ws_position + ws_direction * near + right_view_ax * near_width_half - up_view_ax * near_height_half, 1.0f)); -#endif - - ls_corners[flt] = mul(to_light, center_far - cam_right * far_width_half + cam_up * far_height_half); - ls_corners[flb] = mul(to_light, center_far - cam_right * far_width_half - cam_up * far_height_half); - ls_corners[frt] = mul(to_light, center_far + cam_right * far_width_half + cam_up * far_height_half); - ls_corners[frb] = mul(to_light, center_far + cam_right * far_width_half - cam_up * far_height_half); - ls_corners[nlt] = mul(to_light, center_near - cam_right * near_width_half + cam_up * near_height_half); - ls_corners[nlb] = mul(to_light, center_near - cam_right * near_width_half - cam_up * near_height_half); - ls_corners[nrt] = mul(to_light, center_near + cam_right * near_width_half + cam_up * near_height_half); - ls_corners[nrb] = mul(to_light, center_near + cam_right * near_width_half - cam_up * near_height_half); - - float x_min, x_max, y_min, y_max, z_min, z_max; - - x_min = x_max = ls_corners[0].x; - y_min = y_max = ls_corners[0].y; - z_min = z_max = ls_corners[0].z; - - for (uint32_t i = 1; i < 8; ++i) { - if (x_min > ls_corners[i].x) x_min = ls_corners[i].x; - if (x_max < ls_corners[i].x) x_max = ls_corners[i].x; - - if (y_min > ls_corners[i].y) y_min = ls_corners[i].y; - if (y_max < ls_corners[i].y) y_max = ls_corners[i].y; - - if (z_min > ls_corners[i].z) z_min = ls_corners[i].z; - if (z_max < ls_corners[i].z) z_max = ls_corners[i].z; - } - - { - float tmp = y_max; - y_max = y_min; - y_min = tmp; - } - - - float4x4 projection =(float4x4( - float4(2.0f / (x_max - x_min), 0.0f, 0.0f, -(x_max + x_min) / (x_max - x_min)), - float4(0.0f, 0.0f, -2.0f / (z_max - z_min), (z_max+z_min) / (z_max - z_min)), - float4(0.0f, 1.0f / (y_max - y_min), 0.0f, -(y_min) / (y_max - y_min)), - float4(0.0f, 0.0f, 0.0f, 1.0f))); - - shadowViewDataBuffer[pushConst.viewIdx].viewProjectionMatrix = mul( - projection, float4x4( - float4(to_light[0].xyz, 0.f), - float4(to_light[1].xyz, 0.f), - float4(to_light[2].xyz, 0.f), - float4(0.f, 0.f, 0.f, 1.f) - ) - ); - - { - shadowViewDataBuffer[pushConst.viewIdx].cameraRight = float4(cam_right, 1.f); - shadowViewDataBuffer[pushConst.viewIdx].cameraUp = float4(cam_up, 1.f); - shadowViewDataBuffer[pushConst.viewIdx].cameraForward = float4(cam_fwd, 1.f); - } -} diff --git a/src/render/shaders/textured_quad.hlsl b/src/render/shaders/textured_quad.hlsl deleted file mode 100644 index 6ff5afec..00000000 --- a/src/render/shaders/textured_quad.hlsl +++ /dev/null @@ -1,64 +0,0 @@ -#include "shader_utils.hlsl" - -[[vk::push_constant]] -TexturedQuadPushConst pushConst; - -[[vk::binding(0, 0)]] -Texture2D toDisplay; - -[[vk::binding(1, 0)]] -SamplerState samplerState; - -#if 0 -[numThreads(32, 32, 1)] -[shader("compute")] -void main(uint3 idx : SV_DispatchThreadID) -{ - if (idx.x >= pushConst.extentPixels.x || idx.y >= pushConst.extentPixels.y) { - return; - } - - uint2 dst_pixel = pushConst.startPixels + idx.xy; - uint2 src_pixel = idx.xy; - - // Source pixel UV coordinate for sampling - float2 src_pixel_uv = float2(idx.xy) / float2(pushConst.extentPixels); - float4 src_pixel_value = toDisplay.SampleLevel(samplerState, src_pixel_uv, 0); - - outputImage[dst_pixel] = src_pixel_value; -} -#endif - -struct V2F { - [[vk::location(0)]] float2 uv : TEXCOORD0; -}; - -[shader("vertex")] -float4 vert(in uint vid : SV_VertexID, - out V2F v2f) : SV_Position -{ - float2 vertices[4] = { - pushConst.startPixels, - pushConst.startPixels + float2(pushConst.extentPixels.x, 0), - pushConst.startPixels + float2(0, pushConst.extentPixels.y), - pushConst.startPixels + pushConst.extentPixels, - }; - - v2f.uv = (vertices[vid] - pushConst.startPixels) / pushConst.extentPixels; - - float4 pos = float4(2.0f * (vertices[vid] / pushConst.targetExtent) - float2(1.0, 1.0), 0.0, 1.0); - - return pos; -} - -struct PixelOutput { - float4 color : SV_Target0; -}; - -[shader("pixel")] -PixelOutput frag(in V2F v2f) -{ - PixelOutput output; - output.color = toDisplay.SampleLevel(samplerState, v2f.uv, 0); - return output; -} diff --git a/src/render/shaders/viewer_deferred_lighting.hlsl b/src/render/shaders/viewer_deferred_lighting.hlsl deleted file mode 100644 index 07fdac98..00000000 --- a/src/render/shaders/viewer_deferred_lighting.hlsl +++ /dev/null @@ -1,334 +0,0 @@ -#include "shader_utils.hlsl" - -// GBuffer descriptor bindings - -[[vk::push_constant]] -DeferredLightingPushConst pushConst; - -[[vk::binding(0, 0)]] -RWTexture2D gbufferAlbedo; - -[[vk::binding(1, 0)]] -RWTexture2D gbufferNormal; - -[[vk::binding(2, 0)]] -RWTexture2D gbufferPosition; - -// Assume stuff is Y-UP from here -[[vk::binding(3, 0)]] -StructuredBuffer lights; - -// Atmosphere -[[vk::binding(4, 0)]] -Texture2D transmittanceLUT; - -[[vk::binding(5, 0)]] -Texture2D irradianceLUT; - -[[vk::binding(6, 0)]] -Texture3D scatteringLUT; - -// Shadows -[[vk::binding(7, 0)]] -Texture2D shadowMap; - -// Assume stuff is Y-UP from here -[[vk::binding(8, 0)]] -StructuredBuffer shadowViewDataBuffer; - -// Sampler -[[vk::binding(9, 0)]] -SamplerState linearSampler; - -// Assume stuff from here is Y-UP -[[vk::binding(10, 0)]] -StructuredBuffer skyBuffer; - -#include "lighting.h" - -#define SHADOW_BIAS 0.002f - -float linear_step(float low, float high, float v) { - return clamp((v - low) / (high - low), 0, 1); -} - -/* Shadowing is done using variance shadow mapping. */ -float shadowFactorVSM(float3 world_pos, uint2 target_pixel) -{ - uint2 shadow_map_dim; - shadowMap.GetDimensions(shadow_map_dim.x, shadow_map_dim.y); - - float2 texel_size = float2(1.f, 1.f) / float2(shadow_map_dim); - - float4 world_pos_v4 = float4(world_pos.xyz, 1.f); - - /* Light space position */ - float4 ls_pos = mul(shadowViewDataBuffer[pushConst.viewIdx].viewProjectionMatrix, - world_pos_v4); - - ls_pos.xyz /= ls_pos.w; - ls_pos.z += SHADOW_BIAS; - - /* UV to use when sampling in the shadow map. */ - float2 uv = ls_pos.xy * 0.5 + float2(0.5, 0.5); - - /* Only deal with points which are within the shadow map. */ - if (uv.x > 1.0 || uv.x < 0.0 || uv.y > 1.0 || uv.y < 0.0 || - ls_pos.z > 1.0 || ls_pos.z < 0.0) - return 1.0; - - float2 moment = shadowMap.SampleLevel(linearSampler, uv, 0); - - float occlusion = 0.0f; - - float pcf_count = 1; - - for (int x = int(-pcf_count); x <= int(pcf_count); ++x) { - for (int y = int(-pcf_count); y <= int(pcf_count); ++y) { - float2 moment = shadowMap.SampleLevel(linearSampler, - uv + float2(x, y) * texel_size, 0).rg; - - // Chebychev's inequality - float p = (ls_pos.z > moment.x); - float sigma = max(moment.y - moment.x * moment.x, 0.0); - - float dist_from_mean = (ls_pos.z - moment.x); - - float pmax = linear_step(0.9, 1.0, sigma / (sigma + dist_from_mean * dist_from_mean)); - float occ = min(1.0f, max(pmax, p)); - - occlusion += occ; - } - } - - occlusion /= (pcf_count * 2.0f + 1.0f) * (pcf_count * 2.0f + 1.0f); - - return occlusion; -} - -/* Coordinates which are prefixed with w are in world space. */ -struct GBufferData { - float3 wPosition; - float3 wNormal; - float3 albedo; - float3 wCameraPos; -}; - -/* BRDF calculations. */ -float distributionGGX(float ndoth, float roughness) { - float a2 = roughness * roughness; - float f = (ndoth * a2 - ndoth) * ndoth + 1.0f; - return a2 / (M_PI * f * f); -} - -float smithGGX(float ndotv, float ndotl, float roughness) { - float r = roughness + 1.0; - float k = (r * r) / 8.0; - float ggx1 = ndotv / (ndotv * (1.0f - k) + k); - float ggx2 = ndotl / (ndotl * (1.0f - k) + k); - return ggx1 * ggx2; -} - -float3 fresnel(float hdotv, float3 base) { - return base + (1.0f - base) * pow(1.0f - clamp(hdotv, 0.0f, 1.0f), 5.0f); -} - -float3 fresnelRoughness(float ndotv, float3 base, float roughness) { - float one_minus_rough = 1.0f - roughness; - return base + (max(float3(one_minus_rough, one_minus_rough, one_minus_rough), base) - base) * - pow(1.0f - ndotv, 5.0f); -} - -/* Gets the BRDF contribution on a pixel which lies on a surface for any generic - * incoming radiance value. */ -float3 directionalRadianceBRDF(in GBufferData gbuffer, - in float3 base_reflectivity, - in float roughness, - in float metalness, - in float3 view_direction, - in float3 incoming_radiance, - in float3 light_direction) { - float3 view_dir = -view_direction; - - float3 halfway = normalize(light_direction + view_dir); - - float ndotv = max(dot(gbuffer.wNormal.xyz, view_dir), 0.000001f); - float ndotl = max(dot(gbuffer.wNormal.xyz, light_direction), 0.000001f); - float hdotv = max(dot(halfway, view_dir), 0.000001f); - float ndoth = max(dot(gbuffer.wNormal.xyz, halfway), 0.000001f); - - float distribution_term = distributionGGX(ndoth, roughness); - float smith_term = smithGGX(ndotv, ndotl, roughness); - float3 fresnel_term = fresnel(hdotv, base_reflectivity); - - float3 specular = smith_term * distribution_term * fresnel_term; - specular /= 4.0 * ndotv * ndotl; - - float3 kd = float3(1.0, 1.0, 1.0) - fresnel_term; - - kd *= 1.0f - metalness; - - return (kd * gbuffer.albedo.rgb / M_PI + specular) * incoming_radiance * ndotl; -} - -/* Gets the radiance on a pixel which lies on a surface taking into account BRDF as - * well as the sky transmittance and radiance incoming from the sun. Also takes into - * account shadows using variance shadow maps. */ -float3 accumulateSunRadianceBRDF(in GBufferData gbuffer, - float roughness, - float metal, - float r, - float mu_sun, - uint2 target_pixel) -{ - float3 ret = float3(0.0, 0.0, 0.0); - - /* We get the radiance incoming from the sun to this point by taking into account - * the transmittance of the sky. */ - float3 radiance_from_sun = skyBuffer[0].solarIrradiance.xyz * - getTransmittanceToSun(skyBuffer[0], transmittanceLUT, r, mu_sun); - - ShaderLightData light = unpackLightData(lights[0]); - ret += directionalRadianceBRDF(gbuffer, - lerp(float3(0.04, 0.04, 0.04), gbuffer.albedo.rgb, metal), - roughness, - metal, - normalize(gbuffer.wPosition.xyz - pushConst.viewPos.xyz), - radiance_from_sun, - normalize(-light.direction.xyz)); - - float shadow_factor = shadowFactorVSM(gbuffer.wPosition, target_pixel); - - return ret * shadow_factor; -} - -/* Caculates the radiance which actually arrives to the camera. This will require calling - * accumulateSunRadianceBRDF to get the radiance hitting the surface, and then using - * getSkyRadianceToPoint to calculate the amount of in/out scatter happening on the ray - * from the surface point to the camera. */ -float4 getPointRadianceBRDF(float roughness, float metal, in GBufferData gbuffer, uint2 target_pixel) -{ - float3 sky_irradiance, sun_irradiance, point_radiance; - ShaderLightData light = unpackLightData(lights[0]); - - { /* Calculate sun and sky irradiance which will contribute to the final BRDF. */ - float3 p = gbuffer.wPosition / 1000.0 - skyBuffer[0].wPlanetCenter.xyz; - float3 normal = gbuffer.wNormal; - - float3 sun_direction = -normalize(light.direction.xyz); - - float3 view_direction = normalize(gbuffer.wPosition - pushConst.viewPos.xyz); - - float r = length(p); - float mu_sun = dot(p, sun_direction) / r; - - sky_irradiance = getIrradiance(skyBuffer[0], irradianceLUT, r, mu_sun) * - (1.0 + dot(normal, p) / r) * 0.5; - - float3 accumulated_radiance = accumulateSunRadianceBRDF(gbuffer, roughness, metal, - r, mu_sun, target_pixel); - - point_radiance = accumulated_radiance + gbuffer.albedo.rgb * (1.0 / M_PI) * sky_irradiance; - } - - /* How much is scattered towards us. */ - float3 transmittance; - float3 in_scatter = getSkyRadianceToPoint(skyBuffer[0], transmittanceLUT, - scatteringLUT, scatteringLUT, - pushConst.viewPos.xyz / 1000.0 - skyBuffer[0].wPlanetCenter.xyz, - gbuffer.wPosition / 1000.0 - skyBuffer[0].wPlanetCenter.xyz, 0.0, - -normalize(light.direction.xyz), - transmittance); - - point_radiance = point_radiance * transmittance + in_scatter; - - return float4(point_radiance, 1.0); -} - -/* Calculates the outgoing camera view ray for a given pixel. */ -float3 getOutgoingRay(float2 target_pixel, float2 target_dim) -{ - float aspect_ratio = target_dim.x / target_dim.y; - float tan_fov = tan(pushConst.fovy / 2.0f); - - float right_scale = aspect_ratio * tan_fov; - float up_scale = tan_fov; - - float2 raster = float2(target_pixel.x + 0.5, target_pixel.y + 0.5); - float2 screen = float2((2.0f * raster.x) / target_dim.x - 1.0f, - (2.0f * raster.y) / target_dim.y - 1.0f); - - float3 cam_right = shadowViewDataBuffer[pushConst.viewIdx].cameraRight.xyz; - float3 cam_up = shadowViewDataBuffer[pushConst.viewIdx].cameraUp.xyz; - float3 cam_forward = shadowViewDataBuffer[pushConst.viewIdx].cameraForward.xyz; - - float3 dir = screen.x * cam_right * right_scale - screen.y * cam_up * up_scale + cam_forward; - - return normalize(dir); -} - -[numThreads(32, 32, 1)] -[shader("compute")] -void lighting(uint3 idx : SV_DispatchThreadID) -{ - uint2 target_dim; - gbufferAlbedo.GetDimensions(target_dim.x, target_dim.y); - - if (idx.x < target_dim.x && idx.y < target_dim.y) - { - uint2 target_pixel = uint2(idx.x, idx.y); - - float3 outgoing_ray = getOutgoingRay((float2)target_pixel, (float2)target_dim); - - float4 color = gbufferAlbedo[target_pixel]; - - float4 normal = gbufferNormal[target_pixel]; - float4 position = gbufferPosition[target_pixel]; - - /* If normal.w is more than 0, this object was rasterized. */ - float point_alpha = normal.w; - - GBufferData gbuffer_data; - gbuffer_data.wPosition = position.xyz; - gbuffer_data.wNormal = normal.xyz; - gbuffer_data.albedo = color.rgb; - gbuffer_data.wCameraPos = pushConst.viewPos.xyz; - - float roughness = color.a; - float metalness = position.a; - - /* Radiance from the rasterized pixel. */ - float4 point_radiance = getPointRadianceBRDF(roughness, metalness, - gbuffer_data, target_pixel); - - ShaderLightData light = unpackLightData(lights[0]); - float3 sun_direction = normalize(-light.direction.xyz); - - /* Incoming radiance from the sky: */ - float3 transmittance; - float3 radiance = getSkyRadiance(skyBuffer[0], transmittanceLUT, - scatteringLUT, scatteringLUT, - (gbuffer_data.wCameraPos / 1000.0 - skyBuffer[0].wPlanetCenter.xyz), - outgoing_ray, 0.0, sun_direction, - transmittance); - - if (dot(outgoing_ray, sun_direction) > - skyBuffer[0].sunSize.y * 0.99999) { - radiance = radiance + transmittance * getSolarRadiance(skyBuffer[0]) * 0.06; - } - - radiance = lerp(radiance, point_radiance.xyz, point_alpha); - - /* Tone Mapping. */ - float3 one = float3(1.0, 1.0, 1.0); - float3 exp_value = exp(-radiance / float3(2.0f, 2.0f, 2.0f) * pushConst.exposure); - - float3 diff = one - exp_value; - float3 out_color = diff; - - //float viz = abs(dot(normal.xyz, outgoing_ray)); - //out_color = float3(viz, viz, viz) * color.xyz + 1e-6f * out_color; - - gbufferAlbedo[target_pixel] = float4(out_color, 1.0); - } -} diff --git a/src/render/shaders/viewer_shadow_draw.hlsl b/src/render/shaders/viewer_shadow_draw.hlsl deleted file mode 100644 index 2bcbb50e..00000000 --- a/src/render/shaders/viewer_shadow_draw.hlsl +++ /dev/null @@ -1,76 +0,0 @@ -#include "shader_utils.hlsl" - -[[vk::push_constant]] -DrawPushConst push_const; - -[[vk::binding(0, 0)]] -StructuredBuffer flycamBuffer; - -[[vk::binding(1, 0)]] -StructuredBuffer engineInstanceBuffer; - -[[vk::binding(2, 0)]] -StructuredBuffer drawDataBuffer; - -[[vk::binding(3, 0)]] -StructuredBuffer shadowViewDataBuffer; - -[[vk::binding(4, 0)]] -StructuredBuffer viewDataBuffer; - -[[vk::binding(5, 0)]] -StructuredBuffer viewOffsetsBuffer; - -// Asset descriptor bindings - -[[vk::binding(0, 1)]] -StructuredBuffer vertexDataBuffer; - -[[vk::binding(1, 1)]] -StructuredBuffer materialBuffer; - -#if 0 -struct V2F { - [[vk::location(0)]] float depth : TEXCOORD0; -}; -#endif - - -[shader("vertex")] -float4 vert(in uint vid : SV_VertexID, - in uint draw_id : SV_InstanceID) : SV_Position -{ - Vertex vert = unpackVertex(vertexDataBuffer[vid]); - DrawData draw_data = drawDataBuffer[draw_id]; - float4 color = materialBuffer[vert.materialIdx].color; - uint instance_id = draw_data.instanceID; - - float4x4 shadow_matrix = shadowViewDataBuffer[push_const.viewIdx].viewProjectionMatrix; - - EngineInstanceData instance_data = unpackEngineInstanceData( - engineInstanceBuffer[instance_id]); - - float dummy = 0.00000000001f * float(drawDataBuffer[0].materialID + - flycamBuffer[0].data[0].w + materialBuffer[0].color.w + - viewDataBuffer[0].data[0].w + float(viewOffsetsBuffer[0])); - - float4 world_space_pos = float4( - instance_data.position + mul(toMat(instance_data.rotation), (instance_data.scale * vert.position)), - 1.f + dummy); - - float4 clip_pos = mul(shadow_matrix, world_space_pos); - - return clip_pos; -} - -[shader("pixel")] -float2 frag(in float4 position : SV_Position) : SV_Target0 -{ - float depth = position.z; - - float dx = ddx(depth); - float dy = ddy(depth); - float sigma = depth * depth + 0.25 * (dx * dx + dy * dy); - - return float2(depth, sigma); -} diff --git a/src/render/shaders/voxel_draw.hlsl b/src/render/shaders/voxel_draw.hlsl deleted file mode 100644 index d3eb4aa5..00000000 --- a/src/render/shaders/voxel_draw.hlsl +++ /dev/null @@ -1,114 +0,0 @@ -#include "shader_utils.hlsl" - -[[vk::push_constant]] -DrawPushConst push_const; - -[[vk::binding(0, 0)]] -StructuredBuffer flycamBuffer; - -// Asset descriptor bindings -[[vk::binding(1, 0)]] -StructuredBuffer vertexDataBuffer; - -[[vk::binding(2, 0)]] -StructuredBuffer viewDataBuffer; - -[[vk::binding(3, 0)]] -StructuredBuffer viewOffsetsBuffer; - -// Texture descriptor bindings -[[vk::binding(0, 1)]] -Texture2D materialTexturesArray[]; - -[[vk::binding(1, 1)]] -SamplerState linearSampler; - -[[vk::binding(2, 1)]] -StructuredBuffer materialTexturesIndices; - -struct V2F { - [[vk::location(0)]] float3 normal : TEXCOORD0; - [[vk::location(1)]] float3 position : TEXCOORD1; - [[vk::location(2)]] float4 color : TEXCOORD2; - [[vk::location(3)]] float dummy : TEXCOORD3; - [[vk::location(4)]] float2 uv : TEXCOORD4; - [[vk::location(5)]] int texIdx : TEXCOORD5; - [[vk::location(6)]] float roughness : TEXCOORD6; - [[vk::location(7)]] float metalness : TEXCOORD7; -}; - -[shader("vertex")] -float4 vert(in uint vid : SV_VertexID, - in uint draw_id : SV_InstanceID, - out V2F v2f) : SV_Position -{ - Vertex vert = unpackVertex(vertexDataBuffer[vid]); - float4 color = float4(1,1,1,1); - - PerspectiveCameraData view_data = getCameraData(); - //unpackViewData(viewDataBuffer[push_const.viewIdx]); - - float3 to_view_translation; - float4 to_view_rotation; - - float3 objectScale = float3(1,1,1); - float3 objectPos = float3(0,0,0); - float4 objectRotation = float4(0,0,0,1); - - computeCompositeTransform(objectPos, objectRotation, - view_data.pos, view_data.rot, - to_view_translation, to_view_rotation); - - float3 view_pos = - rotateVec(to_view_rotation, objectScale * vert.position) + - to_view_translation; - - float4 clip_pos = projectToClip(view_data, view_pos, view_data.zNear); - // v2f.viewPos = view_pos; -#if 0 - v2f.normal = normalize( - rotateVec(to_view_rotation, (vert.normal / objectScale))); -#endif - v2f.normal = normalize( - rotateVec(objectRotation, (vert.normal / objectScale))); - v2f.uv = vert.uv; - v2f.color = color; - v2f.position = rotateVec(objectRotation, - objectScale * vert.position) + objectPos; - v2f.dummy = 1; - v2f.texIdx = 2; - v2f.roughness = 0; - v2f.metalness = 0; - - return clip_pos; -} - -struct PixelOutput { - float4 color : SV_Target0; - float4 normal : SV_Target1; - float4 position : SV_Target2; -}; - -[shader("pixel")] -PixelOutput frag(in V2F v2f) -{ - PixelOutput output; - output.color = v2f.color; - output.color.a = v2f.roughness; - output.normal = float4(normalize(v2f.normal), 1.f); - output.position = float4(v2f.position, v2f.dummy * 0.0000001f); - output.position.a += v2f.metalness; - - // output.color.rgb = v2f.normal.xyz; - - if ( v2f.texIdx != -1) { - output.color *= materialTexturesArray[v2f.texIdx].SampleLevel( - linearSampler, float2(v2f.uv.x, 1.f - v2f.uv.y), 0); - } - - //output.color = max(float4(1,1,1,1),output.color); - //output.color = max(float4(1,1,1,1),output.color); - //output.color = output.color*float4((output.normal.xyz+float3(1,1,1))*0.5,1); - - return output; -} diff --git a/src/render/shaders/voxel_gen.hlsl b/src/render/shaders/voxel_gen.hlsl deleted file mode 100644 index bec853c4..00000000 --- a/src/render/shaders/voxel_gen.hlsl +++ /dev/null @@ -1,500 +0,0 @@ -#include "shader_utils.hlsl" - -[[vk::push_constant]] -VoxelGenPushConst pushConst; - -[[vk::binding(0, 0)]] -RWStructuredBuffer vbo; - -[[vk::binding(1, 0)]] -RWStructuredBuffer ibo; - -[[vk::binding(2, 0)]] -StructuredBuffer voxels; - - -int coord(int x,int y,int z,int blockX,int blockY,int blockZ) -{ - return x * blockY * blockZ + y * blockZ + z; -} - -#define xDim 64 -#define yDim 1 -#define zDim 1 - -[numThreads(xDim, yDim, zDim)] -[shader("compute")] -void voxelGen(uint3 idx : SV_DispatchThreadID) -{ - - int indexVal = 0; - float halfBlockSize = pushConst.blockWidth/2; - float texAtlasStep = 1.0 / pushConst.numBlocks; - - int workPerThread = ceil(pushConst.worldX / (float)xDim); - - for(int i2=0;i2= pushConst.worldX){ - return; - } - - for (int j = 0; j < pushConst.worldY; j++) { - for (int k = 0; k < pushConst.worldZ; k++) { - uint data = voxels[coord(i,j,k,pushConst.worldX,pushConst.worldY,pushConst.worldZ)]; - if (data != 0) { - float blockCenterX = halfBlockSize + pushConst.blockWidth * i; - float blockCenterY = halfBlockSize + pushConst.blockWidth * j; - float blockCenterZ = halfBlockSize + pushConst.blockWidth * k; - - int index = 32*6*coord(i,j,k,pushConst.worldX,pushConst.worldY,pushConst.worldZ); - int indexindex = 6*6*coord(i,j,k,pushConst.worldX,pushConst.worldY,pushConst.worldZ); - int vertexIndex = coord(i,j,k,pushConst.worldX,pushConst.worldY,pushConst.worldZ)*6*4; - - int blockID = data - 1; - - uint leftN = (i > 0) ? voxels[coord(i-1,j,k,pushConst.worldX,pushConst.worldY,pushConst.worldZ)]: 0; - if (!leftN) { - //position - vbo[index + 0] = blockCenterX - halfBlockSize; - vbo[index + 1] = blockCenterY - halfBlockSize; - vbo[index + 2] = blockCenterZ - halfBlockSize; - - //tex coord - vbo[index + 3] = texAtlasStep * (blockID); - vbo[index + 4] = -0.333; - - //normals - vbo[index+5] = -1; - vbo[index+6] = 0; - vbo[index+7] = 0; - - - //position - vbo[index + 8] = blockCenterX - halfBlockSize; - vbo[index + 9] = blockCenterY + halfBlockSize; - vbo[index + 10] = blockCenterZ - halfBlockSize; - - //tex coord - vbo[index + 11] = texAtlasStep * (blockID + 1); - vbo[index + 12] = -0.333; - - //normals - vbo[index+13] = -1; - vbo[index+14] = 0; - vbo[index+15] = 0; - - - //position - vbo[index + 16] = blockCenterX - halfBlockSize; - vbo[index + 17] = blockCenterY - halfBlockSize; - vbo[index + 18] = blockCenterZ + halfBlockSize; - - //tex coord - vbo[index + 19] = texAtlasStep * (blockID); - vbo[index + 20] = 0; - - //normals - vbo[index+21] = -1; - vbo[index+22] = 0; - vbo[index+23] = 0; - - - //position - vbo[index + 24] = blockCenterX - halfBlockSize; - vbo[index + 25] = blockCenterY + halfBlockSize; - vbo[index + 26] = blockCenterZ + halfBlockSize; - - //tex coord - vbo[index + 27] = texAtlasStep * (blockID + 1); - vbo[index + 28] = 0; - - //normals - vbo[index+29] = -1; - vbo[index+30] = 0; - vbo[index+31] = 0; - } - - ibo[indexindex+0] = vertexIndex + 2; - ibo[indexindex+1] = vertexIndex + 1; - ibo[indexindex+2] = vertexIndex + 0; - ibo[indexindex+3] = vertexIndex + 3; - ibo[indexindex+4] = vertexIndex + 1; - ibo[indexindex+5] = vertexIndex + 2; - - - index+=32; - indexindex+=6; - vertexIndex+=4; - - uint rightN = (i < pushConst.worldX - 1) ? voxels[coord(i+1,j,k,pushConst.worldX,pushConst.worldY,pushConst.worldZ)] : 0; - if (!rightN) { - //position - vbo[index + 0] = blockCenterX + halfBlockSize; - vbo[index + 1] = blockCenterY - halfBlockSize; - vbo[index + 2] = blockCenterZ - halfBlockSize; - - //tex coord - vbo[index + 3] = texAtlasStep * (blockID); - vbo[index + 4] = -0.333; - - //normals - vbo[index+5] = 1; - vbo[index+6] = 0; - vbo[index+7] = 0; - - - //position - vbo[index + 8] = blockCenterX + halfBlockSize; - vbo[index + 9] = blockCenterY + halfBlockSize; - vbo[index + 10] = blockCenterZ - halfBlockSize; - - //tex coord - vbo[index + 11] = texAtlasStep * (blockID + 1); - vbo[index + 12] = -0.333; - - //normals - vbo[index+13] = 1; - vbo[index+14] = 0; - vbo[index+15] = 0; - - - //position - vbo[index + 16] = blockCenterX + halfBlockSize; - vbo[index + 17] = blockCenterY - halfBlockSize; - vbo[index + 18] = blockCenterZ + halfBlockSize; - - //tex coord - vbo[index + 19] = texAtlasStep * (blockID); - vbo[index + 20] = 0; - - //normals - vbo[index+21] = 1; - vbo[index+22] = 0; - vbo[index+23] = 0; - - - //position - vbo[index + 24] = blockCenterX + halfBlockSize; - vbo[index + 25] = blockCenterY + halfBlockSize; - vbo[index + 26] = blockCenterZ + halfBlockSize; - - //tex coord - vbo[index + 27] = texAtlasStep * (blockID + 1); - vbo[index + 28] = 0; - - //normals - vbo[index+29] = 1; - vbo[index+30] = 0; - vbo[index+31] = 0; - } - - ibo[indexindex+0] = vertexIndex + 0; - ibo[indexindex+1] = vertexIndex + 1; - ibo[indexindex+2] = vertexIndex + 2; - ibo[indexindex+3] = vertexIndex + 2; - ibo[indexindex+4] = vertexIndex + 1; - ibo[indexindex+5] = vertexIndex + 3; - - index+=32; - indexindex+=6; - vertexIndex+=4; - - uint topN = (k < pushConst.worldZ - 1) ? voxels[coord(i,j,k+1,pushConst.worldX,pushConst.worldY,pushConst.worldZ)] : 0; - if (!topN) { - //position - vbo[index + 0] = (blockCenterX - halfBlockSize); - vbo[index + 1] = blockCenterY - halfBlockSize; - vbo[index + 2] = blockCenterZ + halfBlockSize; - - //tex coord - vbo[index + 3] = texAtlasStep * (blockID); - vbo[index + 4] = 0.333; - - //normals - vbo[index+5] = 0; - vbo[index+6] = 0; - vbo[index+7] = 1; - - - //position - vbo[index + 8] = blockCenterX + halfBlockSize; - vbo[index + 9] = blockCenterY - halfBlockSize; - vbo[index + 10] = blockCenterZ + halfBlockSize; - - //tex coord - vbo[index + 11] = texAtlasStep * (blockID); - vbo[index + 12] = 0.667; - - //normals - vbo[index+13] = 0; - vbo[index+14] = 0; - vbo[index+15] = 1; - - - //position - vbo[index + 16] = blockCenterX - halfBlockSize; - vbo[index + 17] = blockCenterY + halfBlockSize; - vbo[index + 18] = blockCenterZ + halfBlockSize; - - //tex coord - vbo[index + 19] = texAtlasStep * (blockID+1); - vbo[index + 20] = 0.333; - - //normals - vbo[index+21] = 0; - vbo[index+22] = 0; - vbo[index+23] = 1; - - - //position - vbo[index + 24] = blockCenterX + halfBlockSize; - vbo[index + 25] = blockCenterY + halfBlockSize; - vbo[index + 26] = blockCenterZ + halfBlockSize; - - //tex coord - vbo[index + 27] = texAtlasStep * (blockID + 1); - vbo[index + 28] = 0.667; - - //normals - vbo[index+29] = 0; - vbo[index+30] = 0; - vbo[index+31] = 1; - } - - ibo[indexindex+0] = vertexIndex + 0; - ibo[indexindex+1] = vertexIndex + 1; - ibo[indexindex+2] = vertexIndex + 2; - ibo[indexindex+3] = vertexIndex + 2; - ibo[indexindex+4] = vertexIndex + 1; - ibo[indexindex+5] = vertexIndex + 3; - - index+=32; - indexindex+=6; - vertexIndex+=4; - - uint bottomN = (k > 0) ? voxels[coord(i,j,k-1,pushConst.worldX,pushConst.worldY,pushConst.worldZ)] : 0; - if (!bottomN) { - //position - vbo[index + 0] = blockCenterX - halfBlockSize; - vbo[index + 1] = blockCenterY - halfBlockSize; - vbo[index + 2] = blockCenterZ - halfBlockSize; - - //tex coord - vbo[index + 3] = texAtlasStep * (blockID); - vbo[index + 4] = 0.667; - - //normals - vbo[index+5] = 0; - vbo[index+6] = 0; - vbo[index+7] = -1; - - - //position - vbo[index + 8] = blockCenterX + halfBlockSize; - vbo[index + 9] = blockCenterY - halfBlockSize; - vbo[index + 10] = blockCenterZ - halfBlockSize; - - //tex coord - vbo[index + 11] = texAtlasStep * (blockID); - vbo[index + 12] = 1; - - //normals - vbo[index+13] = 0; - vbo[index+14] = 0; - vbo[index+15] = -1; - - - //position - vbo[index + 16] = blockCenterX - halfBlockSize; - vbo[index + 17] = blockCenterY + halfBlockSize; - vbo[index + 18] = blockCenterZ - halfBlockSize; - - //tex coord - vbo[index + 19] = texAtlasStep * (blockID+1); - vbo[index + 20] = 0.667; - - //normals - vbo[index+21] = 0; - vbo[index+22] = 0; - vbo[index+23] = -1; - - - //position - vbo[index + 24] = blockCenterX + halfBlockSize; - vbo[index + 25] = blockCenterY + halfBlockSize; - vbo[index + 26] = blockCenterZ - halfBlockSize; - - //tex coord - vbo[index + 27] = texAtlasStep * (blockID + 1); - vbo[index + 28] = 1; - - //normals - vbo[index+29] = 0; - vbo[index+30] = 0; - vbo[index+31] = -1; - } - - ibo[indexindex+0] = vertexIndex + 2; - ibo[indexindex+1] = vertexIndex + 1; - ibo[indexindex+2] = vertexIndex + 0; - ibo[indexindex+3] = vertexIndex + 3; - ibo[indexindex+4] = vertexIndex + 1; - ibo[indexindex+5] = vertexIndex + 2; - - index+=32; - indexindex+=6; - vertexIndex+=4; - - uint frontN = (j < pushConst.worldY - 1) ? voxels[coord(i,j+1,k,pushConst.worldX,pushConst.worldY,pushConst.worldZ)] : 0; - if (!frontN) { - - - //position - vbo[index + 0] = blockCenterX - halfBlockSize; - vbo[index + 1] = blockCenterY + halfBlockSize; - vbo[index + 2] = blockCenterZ - halfBlockSize; - - //tex coord - vbo[index + 3] = texAtlasStep * (blockID); - vbo[index + 4] = -0.333; - - //normals - vbo[index+5] = 0; - vbo[index+6] = 1; - vbo[index+7] = 0; - - - //position - vbo[index + 8] = blockCenterX + halfBlockSize; - vbo[index + 9] = blockCenterY + halfBlockSize; - vbo[index + 10] = blockCenterZ - halfBlockSize; - - //tex coord - vbo[index + 11] = texAtlasStep * (blockID+1); - vbo[index + 12] = -0.333; - - //normals - vbo[index+13] = 0; - vbo[index+14] = 1; - vbo[index+15] = 0; - - - //position - vbo[index + 16] = blockCenterX - halfBlockSize; - vbo[index + 17] = blockCenterY + halfBlockSize; - vbo[index + 18] = blockCenterZ + halfBlockSize; - - //tex coord - vbo[index + 19] = texAtlasStep * (blockID); - vbo[index + 20] = 0; - - //normals - vbo[index+21] = 0; - vbo[index+22] = 1; - vbo[index+23] = 0; - - - //position - vbo[index + 24] = blockCenterX + halfBlockSize; - vbo[index + 25] = blockCenterY + halfBlockSize; - vbo[index + 26] = blockCenterZ + halfBlockSize; - - //tex coord - vbo[index + 27] = texAtlasStep * (blockID + 1); - vbo[index + 28] = 0; - - //normals - vbo[index+29] = 0; - vbo[index+30] = 1; - vbo[index+31] = 0; - } - - ibo[indexindex+0] = vertexIndex + 2; - ibo[indexindex+1] = vertexIndex + 1; - ibo[indexindex+2] = vertexIndex + 0; - ibo[indexindex+3] = vertexIndex + 3; - ibo[indexindex+4] = vertexIndex + 1; - ibo[indexindex+5] = vertexIndex + 2; - - index+=32; - indexindex+=6; - vertexIndex+=4; - - uint backN = (j > 0) ? voxels[coord(i,j-1,k,pushConst.worldX,pushConst.worldY,pushConst.worldZ)] : 0; - if (!backN) { - - - //position - vbo[index + 0] = blockCenterX - halfBlockSize; - vbo[index + 1] = blockCenterY - halfBlockSize; - vbo[index + 2] = blockCenterZ - halfBlockSize; - - //tex coord - vbo[index + 3] = texAtlasStep * (blockID); - vbo[index + 4] = -0.333; - - //normals - vbo[index+5] = 0; - vbo[index+6] = -1; - vbo[index+7] = 0; - - - //position - vbo[index + 8] = blockCenterX + halfBlockSize; - vbo[index + 9] = blockCenterY - halfBlockSize; - vbo[index + 10] = blockCenterZ - halfBlockSize; - - //tex coord - vbo[index + 11] = texAtlasStep * (blockID+1); - vbo[index + 12] = -0.333; - - //normals - vbo[index+13] = 0; - vbo[index+14] = -1; - vbo[index+15] = 0; - - - //position - vbo[index + 16] = blockCenterX - halfBlockSize; - vbo[index + 17] = blockCenterY - halfBlockSize; - vbo[index + 18] = blockCenterZ + halfBlockSize; - - //tex coord - vbo[index + 19] = texAtlasStep * (blockID); - vbo[index + 20] = 0; - - //normals - vbo[index+21] = 0; - vbo[index+22] = -1; - vbo[index+23] = 0; - - - //position - vbo[index + 24] = blockCenterX + halfBlockSize; - vbo[index + 25] = blockCenterY - halfBlockSize; - vbo[index + 26] = blockCenterZ + halfBlockSize; - - //tex coord - vbo[index + 27] = texAtlasStep * (blockID + 1); - vbo[index + 28] = 0; - - //normals - vbo[index+29] = 0; - vbo[index+30] = -1; - vbo[index+31] = 0; - - } - - ibo[indexindex + 0] = vertexIndex + 0; - ibo[indexindex + 1] = vertexIndex + 1; - ibo[indexindex + 2] = vertexIndex + 2; - ibo[indexindex + 3] = vertexIndex + 2; - ibo[indexindex + 4] = vertexIndex + 1; - ibo[indexindex + 5] = vertexIndex + 3; - - } - } - } - } -} From 00c32ee3e405626809c9fc12d02368df3a3b5c6c Mon Sep 17 00:00:00 2001 From: Alexis Duburcq Date: Thu, 23 Jul 2026 13:34:03 +0200 Subject: [PATCH 11/14] Remove the never-dispatched post-process and visualization pipelines postProcess (post_process.hlsl) and createVisualization (visualize_tris.hlsl) are constructed in the BatchRenderer but never bound or dispatched in renderViews. Drop the pipelines and their shaders. The enableAntialiasing render-option field is unrelated (read by no shader) and left in place. --- src/render/batch_renderer.cpp | 23 -- src/render/shaders/post_process.hlsl | 394 ------------------------- src/render/shaders/visualize_tris.hlsl | 26 -- 3 files changed, 443 deletions(-) delete mode 100644 src/render/shaders/post_process.hlsl delete mode 100644 src/render/shaders/visualize_tris.hlsl diff --git a/src/render/batch_renderer.cpp b/src/render/batch_renderer.cpp index cbdc0b87..7b54d310 100644 --- a/src/render/batch_renderer.cpp +++ b/src/render/batch_renderer.cpp @@ -1557,11 +1557,9 @@ struct BatchRenderer::Impl { // Required whether we do batch rendering or not PipelineMP<1> prepareViews; PipelineMP<1> batchDraw; - PipelineMP<1> createVisualization; PipelineMP<1> lighting; PipelineMP<1> shadowGen; PipelineMP<1> shadowDraw; - Optional> postProcess; // Add post-processing pipeline //One frame is on simulation frame HeapArray batchFrames; @@ -1606,11 +1604,6 @@ BatchRenderer::Impl::Impl(const Config &cfg, RenderContext &rctx): dev, rctx.pipelineCache, VK_NULL_HANDLE, consts::numDrawCmdBuffers * cfg.numFrames, 5, rctx.repeatSampler, cfg.maxTextures)), - createVisualization( - makeComputePipeline( - dev, rctx.pipelineCache, 1, sizeof(uint32_t) * 2, - consts::numDrawCmdBuffers * cfg.numFrames, rctx.repeatSampler, - "visualize_tris.hlsl", "visualize", makeShaders)), lighting( makeComputePipeline( dev, rctx.pipelineCache, 3, sizeof(shader::DeferredLightingPushConstBR), @@ -1625,14 +1618,6 @@ BatchRenderer::Impl::Impl(const Config &cfg, RenderContext &rctx): makeShadowDrawPipeline( dev, rctx.pipelineCache, VK_NULL_HANDLE, consts::numDrawCmdBuffers * cfg.numFrames, 3)), - postProcess( - cfg.enableBatchRenderer ? - makeComputePipeline( - dev, rctx.pipelineCache, 1, sizeof(uint32_t) * 4, // push constants for width, height, view count, etc. - consts::numDrawCmdBuffers * cfg.numFrames, rctx.repeatSampler, - "post_process.hlsl", "main", makeShaders) : - Optional>::none() - ), batchFrames(cfg.numFrames), assetSetPrepare(rctx.asset_set_cull_), assetSetDraw(rctx.asset_set_draw_), @@ -1693,9 +1678,6 @@ BatchRenderer::~BatchRenderer() impl->dev.dt.destroyPipeline(impl->dev.hdl, impl->batchDraw.hdls[0], nullptr); impl->dev.dt.destroyPipelineLayout(impl->dev.hdl, impl->batchDraw.layout, nullptr); - impl->dev.dt.destroyPipeline(impl->dev.hdl, impl->createVisualization.hdls[0], nullptr); - impl->dev.dt.destroyPipelineLayout(impl->dev.hdl, impl->createVisualization.layout, nullptr); - impl->dev.dt.destroyPipeline(impl->dev.hdl, impl->lighting.hdls[0], nullptr); impl->dev.dt.destroyPipelineLayout(impl->dev.hdl, impl->lighting.layout, nullptr); @@ -1705,11 +1687,6 @@ BatchRenderer::~BatchRenderer() impl->dev.dt.destroyPipeline(impl->dev.hdl, impl->shadowDraw.hdls[0], nullptr); impl->dev.dt.destroyPipelineLayout(impl->dev.hdl, impl->shadowDraw.layout, nullptr); - if (impl->postProcess.has_value()) { - impl->dev.dt.destroyPipeline(impl->dev.hdl, impl->postProcess->hdls[0], nullptr); - impl->dev.dt.destroyPipelineLayout(impl->dev.hdl, impl->postProcess->layout, nullptr); - } - for (CountT i = 0; i < impl->batchFrames.size(); i++) { impl->dev.dt.destroyCommandPool(impl->dev.hdl, impl->batchFrames[i].prepareCmdPool, nullptr); impl->dev.dt.destroyCommandPool(impl->dev.hdl, impl->batchFrames[i].renderCmdPool, nullptr); diff --git a/src/render/shaders/post_process.hlsl b/src/render/shaders/post_process.hlsl deleted file mode 100644 index 65b4c46c..00000000 --- a/src/render/shaders/post_process.hlsl +++ /dev/null @@ -1,394 +0,0 @@ -#include "shader_utils.hlsl" - -// Post-processing push constants -struct PostProcessPushConst { - uint32_t viewWidth; - uint32_t viewHeight; - uint32_t totalViews; - uint32_t blurRadius; // blur kernel radius -}; - -[[vk::push_constant]] -PostProcessPushConst pushConst; - -// Input/Output buffers -[[vk::binding(0, 0)]] -RWStructuredBuffer rgbBuffer; // Input and output RGB buffer - -// ------------------------------------------------------------------------------------------------ -// Sample RGB from buffer with bounds checking -uint32_t sampleRGB(uint32_t view_idx, int2 coord) { - // Clamp coordinates to view bounds - coord = clamp(coord, int2(0, 0), int2(pushConst.viewWidth - 1, pushConst.viewHeight - 1)); - - uint32_t pixel_idx = view_idx * pushConst.viewWidth * pushConst.viewHeight + - coord.y * pushConst.viewWidth + coord.x; - - return rgbBuffer[pixel_idx]; -} - -// ------------------------------------------------------------------------------------------------ -float3 unpackRGB8(uint32_t packed) { - uint3 rgb; - rgb.r = (packed) & 0xFF; - rgb.g = (packed >> 8) & 0xFF; - rgb.b = (packed >> 16) & 0xFF; - - return float3(rgb) / 255.0f; -} - -// ------------------------------------------------------------------------------------------------ -uint32_t packRGB8(float3 rgb) { - uint3 quant = (uint3)(255 * clamp(rgb, 0.f, 1.f)); - return quant.r | (quant.g << 8) | (quant.b << 16) | ((uint32_t)255 << 24); -} - -// ------------------------------------------------------------------------------------------------ -// Sample RGB as float3 with bounds checking -float3 sampleRGBFloat(uint32_t view_idx, int2 coord) { - return unpackRGB8(sampleRGB(view_idx, coord)); -} - -// ------------------------------------------------------------------------------------------------ -// Bilinear interpolation helper -float3 bilerp(float3 a, float3 b, float3 c, float3 d, float2 t) { - float3 top = lerp(a, b, t.x); // Interpolate between top-left and top-right - float3 bottom = lerp(c, d, t.x); // Interpolate between bottom-left and bottom-right - return lerp(top, bottom, t.y); // Interpolate between top and bottom -} - -// ------------------------------------------------------------------------------------------------ -// Texture sampling with bilinear filtering -// uvs are normalized coordinates [0,1] -// offset is in pixels -// Equivalent to GLSL textureOffset() function -float3 sampleBilinearColor(uint32_t view_idx, float2 uv, float2 offset = float2(0.f, 0.f)) { - // Convert pixel offset to normalized coordinates - float2 offsetUV = float2(offset) / float2(pushConst.viewWidth, pushConst.viewHeight); - - // Apply offset to normalized coordinates - uv += offsetUV; - - // Convert normalized coordinates to pixel coordinates - float2 texelCoord = uv * float2(pushConst.viewWidth, pushConst.viewHeight); - - // Get the integer coordinates of the four surrounding texels - int2 coord00 = int2(floor(texelCoord)); - int2 coord10 = coord00 + int2(1, 0); - int2 coord01 = coord00 + int2(0, 1); - int2 coord11 = coord00 + int2(1, 1); - - // Calculate fractional part for interpolation - float2 fract = texelCoord - float2(coord00); - - // Sample the four surrounding texels - float3 sample00 = sampleRGBFloat(view_idx, coord00); // Top-left - float3 sample10 = sampleRGBFloat(view_idx, coord10); // Top-right - float3 sample01 = sampleRGBFloat(view_idx, coord01); // Bottom-left - float3 sample11 = sampleRGBFloat(view_idx, coord11); // Bottom-right - - // Perform bilinear interpolation - return bilerp(sample00, sample10, sample01, sample11, fract); -} - -// ------------------------------------------------------------------------------------------------ -// Texture sampling with guassian filtering -float3 sampleGaussianColor(uint view_idx, float2 uv, float2 offset = float2(0.f, 0.f)) { - float2 resolution = float2(pushConst.viewWidth, pushConst.viewHeight); - - // Convert pixel offset to normalized coordinates - float2 offsetUV = float2(offset) / resolution; - // Apply offset to normalized coordinates - uv += offsetUV; - - float2 texelCoord = uv * resolution + 0.5; - int2 baseCoord = int2(floor(texelCoord)); - - // Fractional part is ignored here — this is a centered filter. - float3 result = float3(0.0, 0.0, 0.0); - - float weightSum = 0; - - const int2 offsets[9] = { - int2(-1, -1), int2(0, -1), int2(1, -1), - int2(-1, 0), int2(0, 0), int2(1, 0), - int2(-1, 1), int2(0, 1), int2(1, 1) - }; - - const float weights[9] = { - 0.5, 0.7, 0.5, - 0.7, 2.5, 0.7, - 0.5, 0.7, 0.5 - }; - - for (int i = 0; i < 9; ++i) { - int2 coord = baseCoord + offsets[i]; - float3 color = sampleRGBFloat(view_idx, coord); - result += weights[i] * color; - weightSum += weights[i]; - } - - return result / float(weightSum); -} - -// ------------------------------------------------------------------------------------------------ -float3 sampleColor(uint32_t view_idx, float2 uv, float2 offset = float2(0.f, 0.f)) { - return sampleBilinearColor(view_idx, uv, offset); -} - -// ------------------------------------------------------------------------------------------------ -// Similar to textureOffset but return luminance directly -float sampleLuminance(uint32_t view_idx, float2 uv, float2 offset = float2(0.f, 0.f)) { - return rgbToLuminance(sampleColor(view_idx, uv, offset)); -} - -// ------------------------------------------------------------------------------------------------ -// Edge detection result -struct EdgeResult { - bool isEdge; - float lumaRange; -}; - -// given center and neighbor luminances, is anti-aliasing needed? -EdgeResult detectEdge(float lumaM, float lumaN, float lumaS, float lumaE, float lumaW) { - // Find the minimum and maximum luma around the current pixel - float lumaMin = min(lumaM, min(min(lumaN, lumaS), min(lumaE, lumaW))); - float lumaMax = max(lumaM, max(max(lumaN, lumaS), max(lumaE, lumaW))); - - EdgeResult result; - - // Compute the delta - result.lumaRange = lumaMax - lumaMin; - - // If the luma variation is lower than a threshold, we are not on an edge - const float fxaaQualityEdgeThreshold = 0.02; - const float fxaaQualityEdgeMax = 0.125; - - result.isEdge = (result.lumaRange >= max(fxaaQualityEdgeThreshold, lumaMax * fxaaQualityEdgeMax)); - - return result; -} - -// ------------------------------------------------------------------------------------------------ -// is horizontal or vertical edge? -bool isHorizontal(float lumaM, float lumaN, float lumaS, float lumaE, float lumaW, - float lumaNE, float lumaNW, float lumaSE, float lumaSW) { - // Combine the four edge lumas - float lumaNS = lumaN + lumaS; - float lumaWE = lumaW + lumaE; - - // Combine the four corner lumas - float lumaW_ = lumaNW + lumaSW; - float lumaE_ = lumaNE + lumaSE; - float lumaN_ = lumaNW + lumaNE; - float lumaS_ = lumaSW + lumaSE; - - // Compute an estimation of the gradient along the horizontal and vertical axis. - float edgeHoriz = abs(-2.0 * lumaW + lumaW_) + abs(-2.0 * lumaM + lumaNS) * 2.0 + abs(-2.0 * lumaE + lumaE_); - float edgeVert = abs(-2.0 * lumaS + lumaS_) + abs(-2.0 * lumaM + lumaWE) * 2.0 + abs(-2.0 * lumaN + lumaN_); - - // Is the local edge horizontal or vertical? - return edgeHoriz >= edgeVert; -} - -// ------------------------------------------------------------------------------------------------ -float3 applyFXAA(uint32_t view_idx, int2 coord) { - // Sample the center pixel - float2 texelSize = float2(1.0 / float(pushConst.viewWidth), 1.0 / float(pushConst.viewHeight)); - float2 coordf = float2(coord) * texelSize;// + 0.5 * texelSize; - float3 rgbM = sampleColor(view_idx, coordf); - float lumaM = rgbToLuminance(rgbM); - - // 1.Detecting where to apply AA - - // Sample neighbors - float lumaS = sampleLuminance(view_idx, coordf, float2( 0.f, -1.f)); // South - float lumaN = sampleLuminance(view_idx, coordf, float2( 0.f, 1.f)); // North - float lumaE = sampleLuminance(view_idx, coordf, float2(-1.f, 0.f)); // East - float lumaW = sampleLuminance(view_idx, coordf, float2( 1.f, 0.f)); // West - - EdgeResult edgeDetection = detectEdge(lumaM, lumaN, lumaS, lumaE, lumaW); - if (!edgeDetection.isEdge) { - return sampleRGBFloat(view_idx, coord); // No anti-aliasing needed - } - float lumaRange = edgeDetection.lumaRange; - - // 2. Estimating gradient and choosing edge direction - - // Sample the corners - float lumaNW = sampleLuminance(view_idx, coordf, float2( 1.f, 1.f)); - float lumaNE = sampleLuminance(view_idx, coordf, float2(-1.f, 1.f)); - float lumaSW = sampleLuminance(view_idx, coordf, float2( 1.f, -1.f)); - float lumaSE = sampleLuminance(view_idx, coordf, float2(-1.f, -1.f)); - - // Is the local edge horizontal or vertical? - bool horzSpan = isHorizontal(lumaM, lumaN, lumaS, lumaE, lumaW, lumaNE, lumaNW, lumaSE, lumaSW); - - // Select the two neighboring texels lumas in the opposite direction to the local edge - float luma1 = horzSpan ? lumaS : lumaE; - float luma2 = horzSpan ? lumaN : lumaW; - - // Compute gradients in this direction - float gradient1 = luma1 - lumaM; - float gradient2 = luma2 - lumaM; - - // Which direction is the steepest? - bool is1Steepest = abs(gradient1) >= abs(gradient2); - - // Gradient in the corresponding direction, normalized - float gradientScaled = 0.25 * max(abs(gradient1), abs(gradient2)); - - // Choose the step size (how far to go on each iteration) according to the edge direction - float stepLength = horzSpan ? texelSize.y : texelSize.x; - - // Average luma in the correct direction - float lumaLocalAverage = 0.0; - if (is1Steepest) { - // Switch the direction - stepLength = -stepLength; - lumaLocalAverage = 0.5 * (luma1 + lumaM); - } else { - lumaLocalAverage = 0.5 * (luma2 + lumaM); - } - - // Shift UV in the correct direction - float2 currentUv = float2(coordf); - if (horzSpan) { - currentUv.y += stepLength * 0.5; - } else { - currentUv.x += stepLength * 0.5; - } - - // Compute offset (for each iteration step) in the right direction - float2 offset = horzSpan ? float2(texelSize.x, 0.0) : float2(0.0, texelSize.y); - - // Compute UVs to explore on each side of the edge, orthogonally - float2 uv1 = currentUv - offset; - float2 uv2 = currentUv + offset; - - // Read the lumas at both current extremities of the exploration segment - float lumaEnd1 = sampleLuminance(view_idx, uv1); - float lumaEnd2 = sampleLuminance(view_idx, uv2); - - lumaEnd1 -= lumaLocalAverage; - lumaEnd2 -= lumaLocalAverage; - - // If the luma deltas at the current extremities are larger than the local gradient, we have reached the side of the edge - bool reached1 = abs(lumaEnd1) >= gradientScaled; - bool reached2 = abs(lumaEnd2) >= gradientScaled; - bool reachedBoth = reached1 && reached2; - - // If the side is not reached, we continue to explore in this direction - if (!reached1) { - uv1 -= offset; - } - if (!reached2) { - uv2 += offset; - } - - // If both sides have not been reached, continue to explore - #define ITERATIONS 10 - const float QUALITY[ITERATIONS] = {1.5f, 2.0f, 2.0f, 2.0f, 2.0f, 4.0f, 8.0f, 16.0f, 32.0f, 64.0f}; - if (!reachedBoth) { - for (int i = 0; i < ITERATIONS; i++) { // Maximum 12 iterations - // If needed, read luma in 1st direction, compute delta - if (!reached1) { - lumaEnd1 = sampleLuminance(view_idx, uv1); - lumaEnd1 = lumaEnd1 - lumaLocalAverage; - } - // If needed, read luma in opposite direction, compute delta - if (!reached2) { - lumaEnd2 = sampleLuminance(view_idx, uv2); - lumaEnd2 = lumaEnd2 - lumaLocalAverage; - } - // If the luma deltas at the current extremities is larger than the local gradient, we have reached the side of the edge - reached1 = abs(lumaEnd1) >= gradientScaled; - reached2 = abs(lumaEnd2) >= gradientScaled; - reachedBoth = reached1 && reached2; - - // If the side is not reached, we continue to explore in this direction, with a variable quality - if (!reached1) { - uv1 -= offset * QUALITY[i]; - } - if (!reached2) { - uv2 += offset * QUALITY[i]; - } - - // If both sides have been reached, stop the exploration - if (reachedBoth) { - break; - } - } - } - - // Compute the distances to each extremity of the edge - float distance1 = horzSpan ? (coordf.x - uv1.x) : (coordf.y - uv1.y); - float distance2 = horzSpan ? (uv2.x - coordf.x) : (uv2.y - coordf.y); - - // In which direction is the extremity of the edge closer? - bool isDirection1 = distance1 < distance2; - float distanceFinal = min(distance1, distance2); - - // Length of the edge - float edgeLength = (distance1 + distance2); - - // UV offset: read in the direction of the closest side of the edge - float pixelOffset = -distanceFinal / edgeLength + 0.5; - - // Is the luma at center smaller than the local average? - bool isLumaMLowerThanAvg = lumaM < lumaLocalAverage; - - // If the luma at center is smaller than at its neighbor, the delta luma at each end should be positive (same variation) - bool correctVariation = ((isDirection1 ? lumaEnd1 : lumaEnd2) < 0.0) != isLumaMLowerThanAvg; - - // If the luma variation is incorrect, do not offset - float finalOffset = correctVariation ? pixelOffset : 0.0; - - // Sub-pixel shifting - // Full weighted average of the luma over the 3x3 neighborhood - float lumaAverage = (1.0/12.0) * (2.0 * (lumaN + lumaE + lumaS + lumaW) + lumaNE + lumaNW + lumaSE + lumaSW); - - // Ratio of the delta between the global average and the center luma, over the luma range in the 3x3 neighborhood - float subPixelOffset1 = clamp(abs(lumaAverage - lumaM) / lumaRange, 0.0, 1.0); - float subPixelOffset2 = (-2.0 * subPixelOffset1 + 3.0) * subPixelOffset1 * subPixelOffset1; - - // Compute a sub-pixel offset based on this delta - const float fxaaQualitySubpixel = 0.75; - float subPixelOffsetFinal = subPixelOffset2 * subPixelOffset2 * fxaaQualitySubpixel; // Sub-pixel quality - - // Pick the biggest of the two offsets - finalOffset = max(finalOffset, subPixelOffsetFinal); - - // Compute the final UV coordinates - float2 finalUv = float2(coordf); - if (horzSpan) { - finalUv.y += finalOffset * stepLength; - } else { - finalUv.x += finalOffset * stepLength; - } - - // Read the color at the new UV coordinates, and return it - return sampleColor(view_idx, finalUv); -} - -// ------------------------------------------------------------------------------------------------ -[numThreads(32, 32, 1)] -[shader("compute")] -void main(uint3 idx : SV_DispatchThreadID) -{ - if (idx.x >= pushConst.viewWidth || idx.y >= pushConst.viewHeight || idx.z >= pushConst.totalViews) { - return; - } - - uint view_idx = idx.z; - int2 coord = int2(idx.x, idx.y); - - float3 final_color; - - final_color = applyFXAA(view_idx, coord); - - uint32_t out_pixel_idx = view_idx * pushConst.viewWidth * pushConst.viewHeight + - idx.y * pushConst.viewWidth + idx.x; - - rgbBuffer[out_pixel_idx] = packRGB8(final_color); -} diff --git a/src/render/shaders/visualize_tris.hlsl b/src/render/shaders/visualize_tris.hlsl deleted file mode 100644 index d31df5b1..00000000 --- a/src/render/shaders/visualize_tris.hlsl +++ /dev/null @@ -1,26 +0,0 @@ -#include "shader_utils.hlsl" - -[[vk::binding(0, 0)]] -RWTexture2D vizBuffer; - -[[vk::binding(1, 0)]] -RWTexture2D colorBuffer; - -[numThreads(32, 32, 1)] -[shader("compute")] -void visualize(uint3 idx : SV_DispatchThreadID) -{ - uint2 target_dim; - vizBuffer.GetDimensions(target_dim.x, target_dim.y); - - if (idx.x >= target_dim.x || idx.y >= target_dim.y) { - return; - } - - uint2 target_pixel = uint2(idx.x, idx.y); - - uint2 in_value = vizBuffer[target_pixel]; - float4 generated_color = intToColor(hash(in_value.x + in_value.y)); - - colorBuffer[target_pixel] = generated_color; -} From c0de8e19c87e848073622f2bf15f6442ba6af52f Mon Sep 17 00:00:00 2001 From: Alexis Duburcq Date: Thu, 23 Jul 2026 14:04:48 +0200 Subject: [PATCH 12/14] Remove the dead voxel rendering plumbing The voxel shaders are gone and nothing on the rgb/depth/segmentation/ normal path reads the voxel buffer. Remove VoxelConfig from the public render config, the voxel buffer allocation and EngineInterop members, the RenderECSBridge/RenderingSystemState voxel fields, the dangling getVoxelPtr declaration, and the VoxelGenPushConst shader struct. --- include/madrona/render/common.hpp | 7 ----- include/madrona/render/ecs.hpp | 1 - include/madrona/render/render_mgr.hpp | 2 -- src/bridge/mgr.cpp | 1 - src/render/batch_renderer.cpp | 5 ---- src/render/ecs_interop.hpp | 1 - src/render/ecs_system.cpp | 2 -- src/render/render_common.hpp | 8 ------ src/render/render_ctx.cpp | 38 ++------------------------- src/render/render_ctx.hpp | 1 - src/render/shaders/shader_common.h | 8 ------ 11 files changed, 2 insertions(+), 72 deletions(-) diff --git a/include/madrona/render/common.hpp b/include/madrona/render/common.hpp index 021ad0c5..cb0d5190 100644 --- a/include/madrona/render/common.hpp +++ b/include/madrona/render/common.hpp @@ -8,13 +8,6 @@ struct APILib {}; struct APIBackend {}; struct GPUDevice {}; -// If voxel generation is to happen -struct VoxelConfig { - uint32_t xLength; - uint32_t yLength; - uint32_t zLength; -}; - inline float srgbToLinear(float srgb); inline math::Vector4 srgb8ToFloat(uint8_t r, uint8_t g, uint8_t b); diff --git a/include/madrona/render/ecs.hpp b/include/madrona/render/ecs.hpp index 4e9eb5fb..c3b91390 100644 --- a/include/madrona/render/ecs.hpp +++ b/include/madrona/render/ecs.hpp @@ -196,7 +196,6 @@ namespace RenderingSystem { bool update_visual_properties = false); void init(Context &ctx, const RenderECSBridge *bridge); - uint32_t * getVoxelPtr(Context &ctx); void makeEntityRenderable(Context &ctx, Entity e); void disableEntityRenderable(Context &ctx, Entity e); void attachEntityToView(Context &ctx, diff --git a/include/madrona/render/render_mgr.hpp b/include/madrona/render/render_mgr.hpp index 3d428cca..f2c047c6 100644 --- a/include/madrona/render/render_mgr.hpp +++ b/include/madrona/render/render_mgr.hpp @@ -37,8 +37,6 @@ class RenderManager { uint32_t maxLightsPerWorld; uint32_t maxInstancesPerWorld; ExecMode execMode; - - VoxelConfig voxelCfg; }; RenderManager(APIBackend *render_backend, diff --git a/src/bridge/mgr.cpp b/src/bridge/mgr.cpp index 03295bba..83b7d5a8 100644 --- a/src/bridge/mgr.cpp +++ b/src/bridge/mgr.cpp @@ -101,7 +101,6 @@ static inline Optional initRenderManager( .maxLightsPerWorld = gs_model.numLights, .maxInstancesPerWorld = max_instances_per_world, .execMode = ExecMode::CUDA, - .voxelCfg = {}, }); } diff --git a/src/render/batch_renderer.cpp b/src/render/batch_renderer.cpp index 7b54d310..4a1d82e5 100644 --- a/src/render/batch_renderer.cpp +++ b/src/render/batch_renderer.cpp @@ -1978,11 +1978,6 @@ void BatchRenderer::prepareForRendering(BatchRenderInfo info, interop->aabbCPU->flush(impl->dev); } - if (interop->voxelInputCPU.has_value()) { - // Need to flush engine input state before copy - interop->voxelInputCPU->flush(impl->dev); - } - if (interop->lightsCPU.has_value()) { *interop->bridge.totalNumLights = interop->bridge.totalNumLightsCPUInc->load_acquire(); diff --git a/src/render/ecs_interop.hpp b/src/render/ecs_interop.hpp index fc2fcd1c..f334169e 100644 --- a/src/render/ecs_interop.hpp +++ b/src/render/ecs_interop.hpp @@ -27,7 +27,6 @@ struct RenderECSBridge { uint64_t *lightWorldIDs; int32_t renderWidth; int32_t renderHeight; - uint32_t *voxels; uint32_t maxViewsPerworld; uint32_t maxInstancesPerWorld; diff --git a/src/render/ecs_system.cpp b/src/render/ecs_system.cpp index 4b9e6df1..ee243c03 100644 --- a/src/render/ecs_system.cpp +++ b/src/render/ecs_system.cpp @@ -28,7 +28,6 @@ struct RenderingSystemState { uint32_t *totalNumViews; uint32_t *totalNumInstances; uint32_t *totalNumLights; - uint32_t *voxels; float aspectRatio; // This is used if on the CPU backend @@ -655,7 +654,6 @@ void init(Context &ctx, const RenderECSBridge *bridge) #endif system_state.aspectRatio = (float)bridge->renderWidth / (float)bridge->renderHeight; - system_state.voxels = bridge->voxels; } #if 0 diff --git a/src/render/render_common.hpp b/src/render/render_common.hpp index 707c7cee..a7bec041 100644 --- a/src/render/render_common.hpp +++ b/src/render/render_common.hpp @@ -172,14 +172,6 @@ struct EngineInterop { uint32_t maxInstancesPerWorld; uint32_t maxLightsPerWorld; - Optional voxelInputCPU; -#ifdef MADRONA_VK_CUDA_SUPPORT - Optional voxelInputGPU; - Optional voxelInputCUDA; -#endif - - VkBuffer voxelHdl; - uint32_t *iotaArrayInstancesCPU; uint32_t *iotaArrayViewsCPU; uint32_t *iotaArrayLightOffsetsCPU; diff --git a/src/render/render_ctx.cpp b/src/render/render_ctx.cpp index bbde3ef1..f709a959 100644 --- a/src/render/render_ctx.cpp +++ b/src/render/render_ctx.cpp @@ -565,8 +565,7 @@ static EngineInterop setupEngineInterop(Device &dev, uint32_t max_instances_per_world, uint32_t max_lights_per_world, uint32_t render_width, - uint32_t render_height, - VoxelConfig voxel_config) + uint32_t render_height) { (void)dev; @@ -753,31 +752,6 @@ static EngineInterop setupEngineInterop(Device &dev, } } - const uint32_t num_voxels = voxel_config.xLength * voxel_config.yLength * voxel_config.zLength; - const uint32_t staging_size = num_voxels > 0 ? num_voxels * sizeof(int32_t) : 4; - - auto voxel_cpu = Optional::none(); - VkBuffer voxel_buffer_hdl = VK_NULL_HANDLE; - uint32_t *voxel_buffer_ptr = nullptr; - -#ifdef MADRONA_VK_CUDA_SUPPORT - auto voxel_gpu = Optional::none(); - auto voxel_cuda = Optional::none(); -#endif - - if (!gpu_input) { - voxel_cpu = alloc.makeStagingBuffer(staging_size); - voxel_buffer_ptr = num_voxels ? (uint32_t *)voxel_cpu->ptr : nullptr; - voxel_buffer_hdl = voxel_cpu->buffer; - } else { -#ifdef MADRONA_VK_CUDA_SUPPORT - voxel_gpu = alloc.makeDedicatedBuffer(staging_size, false, true); - voxel_cuda.emplace(dev, voxel_gpu->mem, staging_size); - voxel_buffer_hdl = voxel_gpu->buf.buffer; - voxel_buffer_ptr = num_voxels ? (uint32_t *)voxel_cuda->getDevicePointer() : nullptr; -#endif - } - uint32_t *total_num_views_readback = nullptr; uint32_t *total_num_instances_readback = nullptr; uint32_t *total_num_lights_readback = nullptr; @@ -825,7 +799,6 @@ static EngineInterop setupEngineInterop(Device &dev, .lightWorldIDs = (uint64_t *)world_ids_lights_base, .renderWidth = (int32_t)render_width, .renderHeight = (int32_t)render_height, - .voxels = voxel_buffer_ptr, .maxViewsPerworld = max_views_per_world, .maxInstancesPerWorld = max_instances_per_world, .maxLightsPerWorld = max_lights_per_world, @@ -900,12 +873,6 @@ static EngineInterop setupEngineInterop(Device &dev, max_views_per_world, max_instances_per_world, max_lights_per_world, - std::move(voxel_cpu), -#ifdef MADRONA_VK_CUDA_SUPPORT - std::move(voxel_gpu), - std::move(voxel_cuda), -#endif - voxel_buffer_hdl, iota_array_instances, iota_array_views, iota_array_lights, @@ -1327,12 +1294,11 @@ RenderContext::RenderContext( dev, alloc, cfg.execMode == ExecMode::CUDA, cfg.numWorlds, cfg.maxViewsPerWorld, cfg.maxInstancesPerWorld, cfg.maxLightsPerWorld, - br_width_, br_height_, cfg.voxelCfg)), + br_width_, br_height_)), lights_(InternalConfig::maxLights), loaded_assets_(0), sky_(loadSky(dev, alloc, renderQueue)), material_textures_(0), - voxel_config_(cfg.voxelCfg), num_worlds_(cfg.numWorlds), gpu_input_(cfg.execMode == ExecMode::CUDA) { diff --git a/src/render/render_ctx.hpp b/src/render/render_ctx.hpp index 8b6ec624..248d7860 100644 --- a/src/render/render_ctx.hpp +++ b/src/render/render_ctx.hpp @@ -64,7 +64,6 @@ struct RenderContext { Sky sky_; DynArray material_textures_; - VoxelConfig voxel_config_; uint32_t num_worlds_; std::unique_ptr batchRenderer; diff --git a/src/render/shaders/shader_common.h b/src/render/shaders/shader_common.h index d89c32b7..43214a12 100644 --- a/src/render/shaders/shader_common.h +++ b/src/render/shaders/shader_common.h @@ -131,14 +131,6 @@ struct ShadowGenPushConst { uint32_t worldIdx; }; -struct VoxelGenPushConst { - uint32_t worldX; - uint32_t worldY; - uint32_t worldZ; - float blockWidth; - uint32_t numBlocks; -}; - struct Vertex { float3 position; float3 normal; From a67d9d20a733efd043c66c36caca1bcfef922ee1 Mon Sep 17 00:00:00 2001 From: Alexis Duburcq Date: Thu, 23 Jul 2026 14:35:32 +0200 Subject: [PATCH 13/14] Silence CUB 2.8.2 self-inflicted deprecation warnings in the megakernel CUB 2.8.2 (CUDA 12.8) marks its own Traits::CATEGORY deprecated but keeps using it in WarpScan/BlockScan, which our ECS radix sort instantiates. CUB's own _CCCL_SUPPRESS_DEPRECATED_PUSH is a clang pragma that nvrtc/cicc ignores and cannot catch at the instantiation site, so the warnings leak. Define CCCL_IGNORE_DEPRECATED_API (CCCL's documented opt-out) for the nvrtc compile. --- src/mw/CMakeLists.txt | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/mw/CMakeLists.txt b/src/mw/CMakeLists.txt index 1141fb3d..7a430814 100644 --- a/src/mw/CMakeLists.txt +++ b/src/mw/CMakeLists.txt @@ -35,6 +35,11 @@ set(MADRONA_MW_GPU_COMPILE_FLAGS -DTHRUST_DEVICE_SYSTEM=THRUST_DEVICE_SYSTEM_CPP -DCCCL_DISABLE_BF16_SUPPORT=1 -DCUB_DISABLE_BF16_SUPPORT=1 + # CUB 2.8.2 (CUDA 12.8) marks its own Traits::CATEGORY deprecated yet still + # uses it internally in WarpScan/BlockScan; its self-suppression is a clang + # pragma that nvrtc/cicc ignores and cannot catch at our instantiation site. + # CCCL_IGNORE_DEPRECATED_API is CCCL's documented opt-out (drops the attribute). + -DCCCL_IGNORE_DEPRECATED_API=1 ) if (SKBUILD) From 94fd7d78a5e22afd46edce652a8f59f53bcac16a Mon Sep 17 00:00:00 2001 From: Alexis Duburcq Date: Thu, 23 Jul 2026 15:10:32 +0200 Subject: [PATCH 14/14] Remove viewer_draw/viewer_cull shaders and their layout-provider pipelines objectDraw/instanceCull were never dispatched; they existed only so their reflected descriptor-set layouts could back asset_set_mat_tex_/asset_set_cull_, which the live batch path binds. Source those layouts from the batch shaders instead (instanceCull <- prepare_views.hlsl set 2, objectDraw <- batch_draw_rgb.hlsl set 3 - verified layout-identical), store just the PipelineShaders instead of building unused pipelines, and drop the fully-dead asset_set_draw_ pool/set. --- src/render/batch_renderer.cpp | 2 - src/render/render_ctx.cpp | 258 +++------------------------- src/render/render_ctx.hpp | 9 +- src/render/shaders/viewer_cull.hlsl | 112 ------------ src/render/shaders/viewer_draw.hlsl | 173 ------------------- 5 files changed, 25 insertions(+), 529 deletions(-) delete mode 100644 src/render/shaders/viewer_cull.hlsl delete mode 100644 src/render/shaders/viewer_draw.hlsl diff --git a/src/render/batch_renderer.cpp b/src/render/batch_renderer.cpp index 4a1d82e5..41d4a503 100644 --- a/src/render/batch_renderer.cpp +++ b/src/render/batch_renderer.cpp @@ -1565,7 +1565,6 @@ struct BatchRenderer::Impl { HeapArray batchFrames; VkDescriptorSet assetSetPrepare; - VkDescriptorSet assetSetDraw; VkDescriptorSet assetSetTextureMat; VkDescriptorSet assetSetLighting; @@ -1620,7 +1619,6 @@ BatchRenderer::Impl::Impl(const Config &cfg, RenderContext &rctx): consts::numDrawCmdBuffers * cfg.numFrames, 3)), batchFrames(cfg.numFrames), assetSetPrepare(rctx.asset_set_cull_), - assetSetDraw(rctx.asset_set_draw_), assetSetTextureMat(rctx.asset_set_mat_tex_), assetSetLighting(rctx.asset_batch_lighting_set_), renderExtent { cfg.renderWidth, cfg.renderHeight }, diff --git a/src/render/render_ctx.cpp b/src/render/render_ctx.cpp index f709a959..ed0d74a3 100644 --- a/src/render/render_ctx.cpp +++ b/src/render/render_ctx.cpp @@ -57,8 +57,6 @@ using PackedVertex = render::shader::PackedVertex; using MeshData = render::shader::MeshData; using MaterialDataShader = render::shader::MaterialData; using ObjectData = render::shader::ObjectData; -using DrawPushConst = render::shader::DrawPushConst; -using CullPushConst = render::shader::CullPushConst; using DeferredLightingPushConst = render::shader::DeferredLightingPushConst; using DrawCmd = render::shader::DrawCmd; using DrawData = render::shader::DrawData; @@ -277,7 +275,9 @@ static PipelineShaders makeDrawShaders( std::filesystem::path root_dir = py_root_env ? (std::string(py_root_env) + "/src/render") : STRINGIFY(MADRONA_RENDER_DATA_DIR); std::filesystem::path shader_dir = std::filesystem::weakly_canonical(root_dir / "shaders"); - auto shader_path = (shader_dir / "viewer_draw.hlsl").string(); + // Compiled only to reflect the material-texture descriptor-set layout + // (set 3) that the live batch-draw path binds; the pipeline is never built. + auto shader_path = (shader_dir / "batch_draw_rgb.hlsl").string(); ShaderCompiler compiler; SPIRVShader vert_spirv = compiler.compileHLSLFileToSPV( @@ -306,14 +306,21 @@ static PipelineShaders makeDrawShaders( return PipelineShaders(dev, tmp_alloc, shaders, Span({ BindingOverride { - 2, + 3, 0, VK_NULL_HANDLE, max_textures, VK_DESCRIPTOR_BINDING_PARTIALLY_BOUND_BIT, }, BindingOverride { - 2, + 4, + 0, + VK_NULL_HANDLE, + max_textures, + VK_DESCRIPTOR_BINDING_PARTIALLY_BOUND_BIT, + }, + BindingOverride { + 3, 1, repeat_sampler, 1, @@ -328,232 +335,18 @@ static PipelineShaders makeCullShader(const Device &dev) std::filesystem::path root_dir = py_root_env ? (std::string(py_root_env) + "/src/render") : STRINGIFY(MADRONA_RENDER_DATA_DIR); std::filesystem::path shader_dir = std::filesystem::weakly_canonical(root_dir / "shaders"); + // Compiled only to reflect the instance-cull descriptor-set layout (set 2) + // that the live prepare-views path binds; the pipeline is never built. ShaderCompiler compiler; SPIRVShader spirv = compiler.compileHLSLFileToSPV( - (shader_dir / "viewer_cull.hlsl").string().c_str(), {}, - {}, { "instanceCull", ShaderStage::Compute }); + (shader_dir / "prepare_views.hlsl").string().c_str(), {}, + {}, { "main", ShaderStage::Compute }); StackAlloc tmp_alloc; return PipelineShaders(dev, tmp_alloc, Span(&spirv, 1), {}); } -static Pipeline<1> makeDrawPipeline(const Device &dev, - VkPipelineCache pipeline_cache, - VkRenderPass render_pass, - VkSampler repeat_sampler, - VkSampler clamp_sampler, - uint32_t num_frames, - uint32_t max_textures) -{ - auto shaders = makeDrawShaders( - dev, repeat_sampler, clamp_sampler, max_textures); - VkPipelineVertexInputStateCreateInfo vert_info {}; - VkPipelineInputAssemblyStateCreateInfo input_assembly_info {}; - VkPipelineViewportStateCreateInfo viewport_info {}; - VkPipelineMultisampleStateCreateInfo multisample_info {}; - VkPipelineRasterizationStateCreateInfo raster_info {}; - - initCommonDrawPipelineInfo(vert_info, input_assembly_info, - viewport_info, multisample_info, raster_info); - - // Depth/Stencil - VkPipelineDepthStencilStateCreateInfo depth_info {}; - depth_info.sType = VK_STRUCTURE_TYPE_PIPELINE_DEPTH_STENCIL_STATE_CREATE_INFO; - depth_info.depthTestEnable = VK_TRUE; - depth_info.depthWriteEnable = VK_TRUE; - depth_info.depthCompareOp = VK_COMPARE_OP_GREATER_OR_EQUAL; - depth_info.depthBoundsTestEnable = VK_FALSE; - depth_info.stencilTestEnable = VK_FALSE; - depth_info.back.compareOp = VK_COMPARE_OP_ALWAYS; - - // Blend - VkPipelineColorBlendAttachmentState blend_attach {}; - blend_attach.blendEnable = VK_FALSE; - blend_attach.colorWriteMask = - VK_COLOR_COMPONENT_R_BIT | VK_COLOR_COMPONENT_G_BIT | - VK_COLOR_COMPONENT_B_BIT | VK_COLOR_COMPONENT_A_BIT; - - array blend_attachments {{ - blend_attach, - blend_attach, - blend_attach - }}; - - VkPipelineColorBlendStateCreateInfo blend_info {}; - blend_info.sType = - VK_STRUCTURE_TYPE_PIPELINE_COLOR_BLEND_STATE_CREATE_INFO; - blend_info.logicOpEnable = VK_FALSE; - blend_info.attachmentCount = - static_cast(blend_attachments.size()); - blend_info.pAttachments = blend_attachments.data(); - - // Dynamic - array dyn_enable { - VK_DYNAMIC_STATE_VIEWPORT, - VK_DYNAMIC_STATE_SCISSOR, - }; - - VkPipelineDynamicStateCreateInfo dyn_info {}; - dyn_info.sType = VK_STRUCTURE_TYPE_PIPELINE_DYNAMIC_STATE_CREATE_INFO; - dyn_info.dynamicStateCount = dyn_enable.size(); - dyn_info.pDynamicStates = dyn_enable.data(); - - // Push constant - VkPushConstantRange push_const { - VK_SHADER_STAGE_VERTEX_BIT | VK_SHADER_STAGE_FRAGMENT_BIT, - 0, - sizeof(DrawPushConst), - }; - - // Layout configuration - - array draw_desc_layouts {{ - shaders.getLayout(0), - shaders.getLayout(1), - shaders.getLayout(2), - }}; - - VkPipelineLayoutCreateInfo gfx_layout_info; - gfx_layout_info.sType = VK_STRUCTURE_TYPE_PIPELINE_LAYOUT_CREATE_INFO; - gfx_layout_info.pNext = nullptr; - gfx_layout_info.flags = 0; - gfx_layout_info.setLayoutCount = static_cast(draw_desc_layouts.size()); - gfx_layout_info.pSetLayouts = draw_desc_layouts.data(); - gfx_layout_info.pushConstantRangeCount = 1; - gfx_layout_info.pPushConstantRanges = &push_const; - - VkPipelineLayout draw_layout; - REQ_VK(dev.dt.createPipelineLayout(dev.hdl, &gfx_layout_info, nullptr, &draw_layout)); - array gfx_stages {{ - { - VK_STRUCTURE_TYPE_PIPELINE_SHADER_STAGE_CREATE_INFO, - nullptr, - 0, - VK_SHADER_STAGE_VERTEX_BIT, - shaders.getShader(0), - "vert", - nullptr, - }, - { - VK_STRUCTURE_TYPE_PIPELINE_SHADER_STAGE_CREATE_INFO, - nullptr, - 0, - VK_SHADER_STAGE_FRAGMENT_BIT, - shaders.getShader(1), - "frag", - nullptr, - }, - }}; - - VkGraphicsPipelineCreateInfo gfx_info; - gfx_info.sType = VK_STRUCTURE_TYPE_GRAPHICS_PIPELINE_CREATE_INFO; - gfx_info.pNext = nullptr; - gfx_info.flags = 0; - gfx_info.stageCount = gfx_stages.size(); - gfx_info.pStages = gfx_stages.data(); - gfx_info.pVertexInputState = &vert_info; - gfx_info.pInputAssemblyState = &input_assembly_info; - gfx_info.pTessellationState = nullptr; - gfx_info.pViewportState = &viewport_info; - gfx_info.pRasterizationState = &raster_info; - gfx_info.pMultisampleState = &multisample_info; - gfx_info.pDepthStencilState = &depth_info; - gfx_info.pColorBlendState = &blend_info; - gfx_info.pDynamicState = &dyn_info; - gfx_info.layout = draw_layout; - gfx_info.renderPass = render_pass; - gfx_info.subpass = 0; - gfx_info.basePipelineHandle = VK_NULL_HANDLE; - gfx_info.basePipelineIndex = -1; - - VkPipeline draw_pipeline; - REQ_VK(dev.dt.createGraphicsPipelines(dev.hdl, pipeline_cache, 1, - &gfx_info, nullptr, &draw_pipeline)); - - FixedDescriptorPool desc_pool(dev, shaders, 0, num_frames); - - return { - std::move(shaders), - draw_layout, - { draw_pipeline }, - std::move(desc_pool), - }; -} - -static Pipeline<1> makeCullPipeline(const Device &dev, - VkPipelineCache pipeline_cache, - CountT num_frames) -{ - PipelineShaders shader = makeCullShader(dev); - - // Push constant - VkPushConstantRange push_const { - VK_SHADER_STAGE_COMPUTE_BIT, - 0, - sizeof(CullPushConst), - }; - - // Layout configuration - std::array desc_layouts { - shader.getLayout(0), - shader.getLayout(1), - }; - - VkPipelineLayoutCreateInfo cull_layout_info; - cull_layout_info.sType = VK_STRUCTURE_TYPE_PIPELINE_LAYOUT_CREATE_INFO; - cull_layout_info.pNext = nullptr; - cull_layout_info.flags = 0; - cull_layout_info.setLayoutCount = - static_cast(desc_layouts.size()); - cull_layout_info.pSetLayouts = desc_layouts.data(); - cull_layout_info.pushConstantRangeCount = 1; - cull_layout_info.pPushConstantRanges = &push_const; - - VkPipelineLayout cull_layout; - REQ_VK(dev.dt.createPipelineLayout(dev.hdl, &cull_layout_info, nullptr, - &cull_layout)); - - std::array compute_infos; -#if 0 - VkPipelineShaderStageRequiredSubgroupSizeCreateInfoEXT subgroup_size; - subgroup_size.sType = - VK_STRUCTURE_TYPE_PIPELINE_SHADER_STAGE_REQUIRED_SUBGROUP_SIZE_CREATE_INFO_EXT; - subgroup_size.pNext = nullptr; - subgroup_size.requiredSubgroupSize = 32; -#endif - - compute_infos[0].sType = VK_STRUCTURE_TYPE_COMPUTE_PIPELINE_CREATE_INFO; - compute_infos[0].pNext = nullptr; - compute_infos[0].flags = 0; - compute_infos[0].stage = { - VK_STRUCTURE_TYPE_PIPELINE_SHADER_STAGE_CREATE_INFO, - nullptr, //&subgroup_size, - VK_PIPELINE_SHADER_STAGE_CREATE_REQUIRE_FULL_SUBGROUPS_BIT_EXT, - VK_SHADER_STAGE_COMPUTE_BIT, - shader.getShader(0), - "instanceCull", - nullptr, - }; - compute_infos[0].layout = cull_layout; - compute_infos[0].basePipelineHandle = VK_NULL_HANDLE; - compute_infos[0].basePipelineIndex = -1; - - std::array pipelines; - REQ_VK(dev.dt.createComputePipelines(dev.hdl, pipeline_cache, - compute_infos.size(), - compute_infos.data(), nullptr, - pipelines.data())); - - FixedDescriptorPool desc_pool(dev, shader, 0, num_frames); - - return Pipeline<1> { - std::move(shader), - cull_layout, - pipelines, - std::move(desc_pool), - }; -} @@ -1277,15 +1070,11 @@ RenderContext::RenderContext( InternalConfig::gbufferFormat, InternalConfig::depthFormat)), shadowPass(makeShadowRenderPass( dev, InternalConfig::varianceFormat, InternalConfig::depthFormat)), - instanceCull(makeCullPipeline(dev, pipelineCache, InternalConfig::numFrames)), - objectDraw(makeDrawPipeline( - dev, pipelineCache, renderPass, repeatSampler, clampSampler, - InternalConfig::numFrames, max_textures_)), - asset_desc_pool_cull_(dev, instanceCull.shaders, 1, 1), - asset_desc_pool_draw_(dev, objectDraw.shaders, 1, 1), - asset_desc_pool_mat_tx_(dev, objectDraw.shaders, 2, 1), + instanceCull(makeCullShader(dev)), + objectDraw(makeDrawShaders(dev, repeatSampler, clampSampler, max_textures_)), + asset_desc_pool_cull_(dev, instanceCull, 2, 1), + asset_desc_pool_mat_tx_(dev, objectDraw, 3, 1), asset_set_cull_(asset_desc_pool_cull_.makeSet()), - asset_set_draw_(asset_desc_pool_draw_.makeSet()), asset_set_mat_tex_(asset_desc_pool_mat_tx_.makeSet()), load_cmd_pool_(makeCmdPool(dev, dev.gfxQF)), load_cmd_(makeCmdBuffer(dev, load_cmd_pool_)), @@ -1549,11 +1338,6 @@ RenderContext::~RenderContext() dev.dt.destroyDescriptorPool(dev.hdl, asset_pool_, nullptr); - dev.dt.destroyPipeline(dev.hdl, objectDraw.hdls[0], nullptr); - dev.dt.destroyPipelineLayout(dev.hdl, objectDraw.layout, nullptr); - - dev.dt.destroyPipeline(dev.hdl, instanceCull.hdls[0], nullptr); - dev.dt.destroyPipelineLayout(dev.hdl, instanceCull.layout, nullptr); dev.dt.destroyRenderPass(dev.hdl, renderPass, nullptr); dev.dt.destroyRenderPass(dev.hdl, shadowPass, nullptr); @@ -2142,14 +1926,12 @@ CountT RenderContext::loadObjects(Span src_objs, vert_info.buffer = asset_buffer.buffer; vert_info.offset = buffer_offsets[1]; vert_info.range = buffer_sizes[2]; - DescHelper::storage(desc_updates.emplace_back(), asset_set_draw_, &vert_info, 0); DescHelper::storage(desc_updates.emplace_back(), asset_batch_lighting_set_, &vert_info, 0); VkDescriptorBufferInfo mat_info; mat_info.buffer = asset_buffer.buffer; mat_info.offset = buffer_offsets[3]; mat_info.range = buffer_sizes[4]; - DescHelper::storage(desc_updates.emplace_back(), asset_set_draw_, &mat_info, 1); DescHelper::storage(desc_updates.emplace_back(), asset_batch_lighting_set_, &mat_info, 2); VkDescriptorBufferInfo index_set_info; diff --git a/src/render/render_ctx.hpp b/src/render/render_ctx.hpp index 248d7860..582971f6 100644 --- a/src/render/render_ctx.hpp +++ b/src/render/render_ctx.hpp @@ -39,15 +39,16 @@ struct RenderContext { VkRenderPass renderPass; VkRenderPass shadowPass; - Pipeline<1> instanceCull; - Pipeline<1> objectDraw; + // Compiled batch-path shaders kept only to source the descriptor-set + // layouts below (instanceCull <- prepare_views set 2, objectDraw <- + // batch_draw_rgb set 3); no pipelines are built from them. + render::vk::PipelineShaders instanceCull; + render::vk::PipelineShaders objectDraw; render::vk::FixedDescriptorPool asset_desc_pool_cull_; - render::vk::FixedDescriptorPool asset_desc_pool_draw_; render::vk::FixedDescriptorPool asset_desc_pool_mat_tx_; VkDescriptorSet asset_set_cull_; - VkDescriptorSet asset_set_draw_; VkDescriptorSet asset_set_mat_tex_; VkCommandPool load_cmd_pool_; diff --git a/src/render/shaders/viewer_cull.hlsl b/src/render/shaders/viewer_cull.hlsl deleted file mode 100644 index f4e0da7f..00000000 --- a/src/render/shaders/viewer_cull.hlsl +++ /dev/null @@ -1,112 +0,0 @@ -#include "shader_utils.hlsl" - -[[vk::push_constant]] -CullPushConst pushConst; - -// Contains the view just for the fly cam -[[vk::binding(0, 0)]] -StructuredBuffer viewDataBuffer; - -// Contains the instances for all the worlds -[[vk::binding(1, 0)]] -StructuredBuffer engineInstanceBuffer; - -[[vk::binding(2, 0)]] -RWStructuredBuffer drawCount; - -[[vk::binding(3, 0)]] -RWStructuredBuffer drawCommandBuffer; - -[[vk::binding(4, 0)]] -RWStructuredBuffer drawDataBuffer; - -[[vk::binding(5, 0)]] -RWStructuredBuffer instanceOffsets; - -// Asset descriptor bindings - -[[vk::binding(0, 1)]] -StructuredBuffer objectDataBuffer; - -[[vk::binding(1, 1)]] -StructuredBuffer meshDataBuffer; - -uint getNumInstancesForWorld(uint world_idx) -{ - if (world_idx == pushConst.numWorlds - 1) { - return pushConst.numInstances - instanceOffsets[world_idx]; - } else { - return instanceOffsets[world_idx+1] - instanceOffsets[world_idx]; - } -} - -uint getInstanceOffsetsForWorld(uint world_idx) -{ - return instanceOffsets[world_idx]; -} - -struct SharedData { - uint numInstances; - uint numInstancesPerThread; - uint instancesOffset; -}; - -groupshared SharedData sm; - -// No actual culling performed yet -[numThreads(32, 1, 1)] -[shader("compute")] -void instanceCull(uint3 tid : SV_DispatchThreadID, - uint3 tid_local : SV_GroupThreadID, - uint3 gid : SV_GroupID) -{ - if (tid_local.x == 0) { - sm.numInstances = getNumInstancesForWorld(pushConst.worldID); - sm.numInstancesPerThread = (sm.numInstances + pushConst.numThreads-1) / - pushConst.numThreads; - sm.instancesOffset = getInstanceOffsetsForWorld(pushConst.worldID); - // printf("%d\n", sm.numInstances); - } - - GroupMemoryBarrierWithGroupSync(); - - for (int i = 0; i < sm.numInstancesPerThread; ++i) { - uint local_idx = i * pushConst.numThreads + tid.x; - - if (local_idx >= sm.numInstances) { - return; - } - - uint current_instance_idx = sm.instancesOffset + local_idx; - - EngineInstanceData instance_data = unpackEngineInstanceData(engineInstanceBuffer[current_instance_idx]); - ObjectData obj = objectDataBuffer[instance_data.objectID]; - - uint draw_offset; - InterlockedAdd(drawCount[0], obj.numMeshes, draw_offset); - - for (int32_t i = 0; i < obj.numMeshes; i++) { - MeshData mesh = meshDataBuffer[obj.meshOffset + i]; - - uint draw_id = draw_offset + i; - DrawCmd draw_cmd; - draw_cmd.indexCount = mesh.numIndices; - draw_cmd.instanceCount = 1; - draw_cmd.firstIndex = mesh.indexOffset; - draw_cmd.vertexOffset = mesh.vertexOffset; - draw_cmd.firstInstance = draw_id; - - DrawData draw_data; - - if (instance_data.matID == -1) { - draw_data.materialID = mesh.materialIndex; - } else { - draw_data.materialID = instance_data.matID; - } - draw_data.instanceID = current_instance_idx; - - drawCommandBuffer[draw_id] = draw_cmd; - drawDataBuffer[draw_id] = draw_data; - } - } -} diff --git a/src/render/shaders/viewer_draw.hlsl b/src/render/shaders/viewer_draw.hlsl deleted file mode 100644 index 24e6e97e..00000000 --- a/src/render/shaders/viewer_draw.hlsl +++ /dev/null @@ -1,173 +0,0 @@ -#include "shader_utils.hlsl" - -[[vk::push_constant]] -DrawPushConst push_const; - -[[vk::binding(0, 0)]] -StructuredBuffer flycamBuffer; - -[[vk::binding(1, 0)]] -StructuredBuffer engineInstanceBuffer; - -[[vk::binding(2, 0)]] -StructuredBuffer drawDataBuffer; - -[[vk::binding(3, 0)]] -StructuredBuffer shadowViewDataBuffer; - -[[vk::binding(4, 0)]] -StructuredBuffer viewDataBuffer; - -[[vk::binding(5, 0)]] -StructuredBuffer viewOffsetsBuffer; - -// Asset descriptor bindings -[[vk::binding(0, 1)]] -StructuredBuffer vertexDataBuffer; - -[[vk::binding(1, 1)]] -StructuredBuffer materialBuffer; - -// Texture descriptor bindings -[[vk::binding(0, 2)]] -Texture2D materialTexturesArray[]; - -[[vk::binding(1, 2)]] -SamplerState linearSampler; - -[[vk::binding(2, 2)]] -StructuredBuffer materialTexturesIndices; - -struct V2F { - [[vk::location(0)]] float3 normal : TEXCOORD0; - [[vk::location(1)]] float3 position : TEXCOORD1; - [[vk::location(2)]] float dummy : TEXCOORD3; - [[vk::location(3)]] float2 uv : TEXCOORD4; - [[vk::location(4)]] int materialIdx : TEXCOORD5; - [[vk::location(5)]] float worldIdx : TEXCOORD6; -}; - -PerspectiveCameraData getCameraData() -{ - PerspectiveCameraData camera_data; - - if (push_const.viewIdx == 0) { - camera_data = unpackViewData(flycamBuffer[0]); - } else { - PerspectiveCameraData fly_cam = unpackViewData(flycamBuffer[0]); - - int view_idx = (push_const.viewIdx - 1) + viewOffsetsBuffer[push_const.worldIdx]; - camera_data = unpackViewData(viewDataBuffer[view_idx]); - - // We want to inherit the aspect ratio from the flycam camera - camera_data.xScale = fly_cam.xScale; - camera_data.yScale = fly_cam.yScale; - } - - return camera_data; -} - -[shader("vertex")] -float4 vert(in uint vid : SV_VertexID, - in uint draw_id : SV_InstanceID, - out V2F v2f) : SV_Position -{ - DrawData draw_data = drawDataBuffer[draw_id]; - Vertex vert = unpackVertex(vertexDataBuffer[vid]); - - uint instance_id = draw_data.instanceID; - EngineInstanceData instance_data = unpackEngineInstanceData(engineInstanceBuffer[instance_id]); - PerspectiveCameraData view_data = getCameraData(); - - float3 to_view_translation; - float4 to_view_rotation; - computeCompositeTransform( - instance_data.position, instance_data.rotation, - view_data.pos, view_data.rot, - to_view_translation, to_view_rotation - ); - - float3 view_pos = rotateVec(to_view_rotation, instance_data.scale * vert.position) + to_view_translation; - -#if 0 - float4 clip_pos = float4( - view_data.xScale * view_pos.x, - view_data.yScale * view_pos.z, - view_data.zNear, - view_pos.y); -#endif - - float4 clip_pos; - - if (push_const.isOrtho == 1) { - float x_max = push_const.xMax * view_data.xScale; - float x_min = push_const.xMin * view_data.xScale; - - float y_max = push_const.yMax; - float y_min = push_const.yMin; - - float z_max = push_const.zMax * (1.0f / -view_data.yScale); - float z_min = push_const.zMin * (1.0f / -view_data.yScale); - - float4x4 m1 = float4x4( - float4(2.0f / (x_max - x_min), 0.0f, 0.0f, -(x_max + x_min) / (x_max - x_min)), - float4(0.0f, 0.0f, -2.0f / (z_max - z_min), -(z_max+z_min) / (z_max - z_min)), - float4(0.0f, 1.0f / (y_max - y_min), 0.0f, -(y_min) / (y_max - y_min)), - float4(0.0f, 0.0f, 0.0f, 1.0f)); - - clip_pos = mul(m1, float4(view_pos.x, view_pos.y, view_pos.z, 1.0f)); - clip_pos.z = 1.0 - clip_pos.z; - } - else { -#if 0 - clip_pos = float4( view_data.xScale * view_pos.x, - view_data.yScale * view_pos.z, - view_data.zNear, - 1.0); -#endif - - clip_pos = projectToClip(view_data, view_pos, view_data.zNear); - } - - v2f.normal = normalize(rotateVec(instance_data.rotation, (vert.normal / instance_data.scale))); - v2f.uv = float2(vert.uv.x, 1.0f - vert.uv.y); - v2f.position = rotateVec(instance_data.rotation, instance_data.scale * vert.position) + instance_data.position; - v2f.dummy = shadowViewDataBuffer[0].viewProjectionMatrix[0][0]; - v2f.materialIdx = draw_data.materialID; - v2f.worldIdx = instance_data.worldID; - - return clip_pos; -} - -struct PixelOutput { - float4 color : SV_Target0; - float4 normal : SV_Target1; - float4 position : SV_Target2; -}; - -[shader("pixel")] -PixelOutput frag(in V2F v2f) -{ - PixelOutput output; - - MaterialData mat_data = materialBuffer[v2f.materialIdx]; - float metalness = mat_data.metalness; - float roughness = mat_data.roughness; - float4 color = mat_data.color; - - int texture_idx = -1; - uint texture_count = mat_data.numTextures; - if (texture_count > 0) { - uint texture_start = mat_data.textureOffset; - texture_idx = materialTexturesIndices[texture_start + v2f.worldIdx % texture_count]; - } - if (texture_idx != -1) { - color *= materialTexturesArray[texture_idx].SampleLevel(linearSampler, v2f.uv, 0); - } - - output.color = color; - output.color.a = roughness; - output.normal = float4(normalize(v2f.normal), 1.f); - output.position = float4(v2f.position, v2f.dummy * 0.0000001f + metalness); - return output; -}