// SPDX-FileCopyrightText: 2025 Erin Catto // SPDX-License-Identifier: MIT #if defined( _MSC_VER ) && !defined( _CRT_SECURE_NO_WARNINGS ) #define _CRT_SECURE_NO_WARNINGS #endif #include "sample.h" #include "benchmarks.h" #include "gfx/debug_adapter.h" #include "gfx/draw.h" #include "gfx/gtao.h" #include "gfx/keycodes.h" #include "gfx/renderer.h" #include "gfx/shadow.h" #include "gfx/text.h" #include "human.h" #include "imgui.h" #include "implot.h" #include "jsmn.h" #include "sokol_app.h" #include "utils.h" #include "box3d/box3d.h" #include #include #include #include #include #include #include #define INFO_PANEL_WIDTH 20.0f // Load a file. You must free the character array. static char* ReadFile( int& size, const char* filename ) { FILE* file = fopen( filename, "rb" ); if ( file == nullptr ) { return nullptr; } fseek( file, 0, SEEK_END ); size = static_cast( ftell( file ) ); fseek( file, 0, SEEK_SET ); if ( size == 0 ) { fclose( file ); return nullptr; } char* data = (char*)malloc( size + 1 ); fread( data, size, 1, file ); fclose( file ); data[size] = 0; return data; } static const char* settingsFileName = "settings.ini"; // Polled key state, fed from the host event loop (see main.cpp). Indexed by // sokol keycode; samples read it through the KEY_* aliases in gfx/keycodes.h. static bool s_keyDown[512]; bool IsKeyDown( int key ) { return (unsigned)key < 512u ? s_keyDown[key] : false; } void SetKeyDown( int key, bool down ) { if ( (unsigned)key < 512u ) { s_keyDown[key] = down; } } void SampleContext::Save() { const b3DebugDraw* gd = GetGuiDraw(); int gtaoQuality = GetGtaoTraceParams().quality; FILE* file = fopen( settingsFileName, "w" ); if ( file == nullptr ) { return; } fprintf( file, "{\n" ); fprintf( file, " \"sampleIndex\": %d,\n", sampleIndex ); fprintf( file, " \"drawShapes\": %s,\n", gd->drawShapes ? "true" : "false" ); fprintf( file, " \"enableShadows\": %s,\n", enableShadows ? "true" : "false" ); fprintf( file, " \"enableGtao\": %s,\n", enableGtao ? "true" : "false" ); fprintf( file, " \"gtaoQuality\": %d,\n", gtaoQuality ); fprintf( file, " \"enableIbl\": %s,\n", GetIblEnabled() ? "true" : "false" ); fprintf( file, " \"exposure\": %g,\n", GetExposure() ); fprintf( file, " \"sunStrength\": %g,\n", GetSun().strength ); fprintf( file, " \"debugView\": %d,\n", debugView ); fprintf( file, " \"drawDistance\": %g,\n", drawDistance ); fprintf( file, " \"showHullEdges\": %s,\n", GetEdgeOverlayParams().showHulls ? "true" : "false" ); fprintf( file, " \"showEdgeConvexity\": %s,\n", GetEdgeOverlayParams().showEdgeConvexity ? "true" : "false" ); fprintf( file, " \"replayKeyframeBudgetMB\": %d,\n", replayKeyframeBudgetMB ); fprintf( file, " \"replayKeyframeMinInterval\": %d\n", replayKeyframeMinInterval ); fprintf( file, "}\n" ); fclose( file ); } #define MAX_TOKENS 64 static int jsoneq( const char* json, jsmntok_t* tok, const char* s ) { if ( tok->type == JSMN_STRING && (int)strlen( s ) == tok->end - tok->start && strncmp( json + tok->start, s, tok->end - tok->start ) == 0 ) { return 0; } return -1; } void SampleContext::Load() { recycleDistance = B3_CONTACT_RECYCLE_DISTANCE; int size = 0; char* data = ReadFile( size, settingsFileName ); if ( data == nullptr ) { return; } // Settings file exists, so it is not a new user. newUser = false; jsmn_parser parser; jsmntok_t tokens[MAX_TOKENS]; jsmn_init( &parser ); int tokenCount = jsmn_parse( &parser, data, size, tokens, MAX_TOKENS ); char buffer[32]; for ( int i = 0; i < tokenCount; ++i ) { if ( jsoneq( data, &tokens[i], "sampleIndex" ) == 0 ) { int count = tokens[i + 1].end - tokens[i + 1].start; assert( count < 32 ); const char* s = data + tokens[i + 1].start; strncpy( buffer, s, count ); buffer[count] = 0; char* dummy; sampleIndex = (int)strtol( buffer, &dummy, 10 ); if ( sampleIndex < 0 ) { sampleIndex = 0; } else if ( sampleIndex >= g_sampleCount ) { sampleIndex = g_sampleCount - 1; } } else if ( jsoneq( data, &tokens[i], "drawShapes" ) == 0 ) { const char* s = data + tokens[i + 1].start; GetGuiDraw()->drawShapes = strncmp( s, "true", 4 ) == 0; } else if ( jsoneq( data, &tokens[i], "enableShadows" ) == 0 ) { const char* s = data + tokens[i + 1].start; enableShadows = strncmp( s, "true", 4 ) == 0; } else if ( jsoneq( data, &tokens[i], "enableGtao" ) == 0 ) { const char* s = data + tokens[i + 1].start; enableGtao = strncmp( s, "true", 4 ) == 0; } else if ( jsoneq( data, &tokens[i], "gtaoQuality" ) == 0 ) { int count = tokens[i + 1].end - tokens[i + 1].start; assert( count < 32 ); const char* s = data + tokens[i + 1].start; strncpy( buffer, s, count ); buffer[count] = 0; int quality = b3ClampInt( (int)strtol( buffer, nullptr, 10 ), 0, 2 ); GtaoTraceParams p = GetGtaoTraceParams(); p.quality = (AmbientOcclusionQuality)quality; SetGtaoTraceParams( p ); } else if ( jsoneq( data, &tokens[i], "enableIbl" ) == 0 ) { const char* s = data + tokens[i + 1].start; SetIblEnabled( strncmp( s, "true", 4 ) == 0 ); } else if ( jsoneq( data, &tokens[i], "exposure" ) == 0 ) { int count = tokens[i + 1].end - tokens[i + 1].start; assert( count < 32 ); const char* s = data + tokens[i + 1].start; strncpy( buffer, s, count ); buffer[count] = 0; SetExposure( strtof( buffer, nullptr ) ); } else if ( jsoneq( data, &tokens[i], "sunStrength" ) == 0 ) { int count = tokens[i + 1].end - tokens[i + 1].start; assert( count < 32 ); const char* s = data + tokens[i + 1].start; strncpy( buffer, s, count ); buffer[count] = 0; Sun sun = GetSun(); sun.strength = strtof( buffer, nullptr ); SetSun( sun ); } else if ( jsoneq( data, &tokens[i], "debugView" ) == 0 ) { int count = tokens[i + 1].end - tokens[i + 1].start; assert( count < 32 ); const char* s = data + tokens[i + 1].start; strncpy( buffer, s, count ); buffer[count] = 0; debugView = b3ClampInt( (int)strtol( buffer, nullptr, 10 ), 0, 4 ); } else if ( jsoneq( data, &tokens[i], "drawDistance" ) == 0 ) { int count = tokens[i + 1].end - tokens[i + 1].start; assert( count < 32 ); const char* s = data + tokens[i + 1].start; strncpy( buffer, s, count ); buffer[count] = 0; drawDistance = b3ClampFloat( strtof( buffer, nullptr ), 1.0f, Camera::kViewDistance ); } else if ( jsoneq( data, &tokens[i], "showHullEdges" ) == 0 ) { const char* s = data + tokens[i + 1].start; EdgeOverlayParams p = GetEdgeOverlayParams(); p.showHulls = strncmp( s, "true", 4 ) == 0; SetEdgeOverlayParams( &p ); } else if ( jsoneq( data, &tokens[i], "showEdgeConvexity" ) == 0 ) { const char* s = data + tokens[i + 1].start; EdgeOverlayParams p = GetEdgeOverlayParams(); p.showEdgeConvexity = strncmp( s, "true", 4 ) == 0; SetEdgeOverlayParams( &p ); } else if ( jsoneq( data, &tokens[i], "replayKeyframeBudgetMB" ) == 0 ) { int count = tokens[i + 1].end - tokens[i + 1].start; if ( count > (int)sizeof( buffer ) - 1 ) { count = (int)sizeof( buffer ) - 1; } const char* s = data + tokens[i + 1].start; memcpy( buffer, s, count ); buffer[count] = 0; replayKeyframeBudgetMB = b3ClampInt( (int)strtol( buffer, nullptr, 10 ), 64, 4096 ); } else if ( jsoneq( data, &tokens[i], "replayKeyframeMinInterval" ) == 0 ) { int count = tokens[i + 1].end - tokens[i + 1].start; if ( count > (int)sizeof( buffer ) - 1 ) { count = (int)sizeof( buffer ) - 1; } const char* s = data + tokens[i + 1].start; memcpy( buffer, s, count ); buffer[count] = 0; replayKeyframeMinInterval = b3ClampInt( (int)strtol( buffer, nullptr, 10 ), 1, 1024 ); } } free( data ); } Sample::Sample( SampleContext* context ) { m_context = context; m_camera = &m_context->camera; if ( m_camera->m_thirdPerson ) { sapp_lock_mouse( false ); m_camera->m_thirdPerson = false; } m_worldId = b3_nullWorldId; m_recording = nullptr; m_recordStartStep = 0; m_mouseBodyId = {}; m_mouseJointId = {}; m_mouseFraction = 0.0f; m_mouseForceScale = 100.0f; m_textLine = 0; m_textIncrement = 22; m_stepCount = 0; m_userMaterialId = 0; memset( m_profiles, 0, sizeof( m_profiles ) ); m_currentProfileIndex = 0; m_profileReadIndex = 0; m_profileWriteIndex = 0; m_stepWhilePaused = true; m_didStep = false; m_haveMouseLast = false; m_mouseLast = { 0.0f, 0.0f }; m_mouseDelta = { 0.0f, 0.0f }; m_launchSpeedScale = 5.0f; g_randomSeed = RAND_SEED; b3Capacity capacity = {}; CreateWorld( &capacity ); } Sample::~Sample() { ResetAdapterPool(); ResetGroundShapeId(); if ( B3_IS_NON_NULL( m_worldId ) ) { FinishRecording(); b3DestroyWorld( m_worldId ); } } void Sample::StartRecording() { if ( m_recording != nullptr ) { return; } // Snapshot the live world as the seed, so recording can begin at any step boundary. m_recording = b3CreateRecording( 0 ); b3World_StartRecording( m_worldId, m_recording ); m_recordStartStep = m_stepCount; } void Sample::FinishRecording() { if ( m_recording == nullptr ) { return; } b3World_StopRecording( m_worldId ); b3SaveRecordingToFile( m_recording, m_context->recordingFile ); b3DestroyRecording( m_recording ); m_recording = nullptr; } void Sample::CreateWorld( b3Capacity* capacity ) { if ( B3_IS_NON_NULL( m_worldId ) ) { FinishRecording(); b3DestroyWorld( m_worldId ); } m_mouseBodyId = {}; m_mouseJointId = {}; m_mousePoint = {}; b3WorldDef worldDef = b3DefaultWorldDef(); worldDef.workerCount = m_context->workerCount; worldDef.enableSleep = m_context->enableSleep; AttachToWorldDef( &worldDef ); if ( capacity != nullptr ) { worldDef.capacity = *capacity; } m_worldId = b3CreateWorld( &worldDef ); b3World_SetContactRecycleDistance( m_worldId, m_context->recycleDistance ); } void Sample::ResetText() { // Below the menu bar when the UI shows. Hidden, the minimal HUD owns the // top-left, so start lower. float fontSize = ImGui::GetFontSize(); if ( m_context->showUI ) { m_textLine = (int)( ImGui::GetFrameHeight() + 0.5f * fontSize ); } else { m_textLine = (int)( 3.0f * fontSize ); } } void Sample::Step() { m_didStep = false; float timeStep = 0.0f; if ( m_context->pause == false || m_context->singleStep > 0 ) { timeStep = m_context->hertz > 0.0f ? 1.0f / m_context->hertz : 0.0f; m_context->singleStep = b3MaxInt( 0, m_context->singleStep - 1 ); } if ( B3_IS_NON_NULL( m_mouseJointId ) && b3Joint_IsValid( m_mouseJointId ) == false ) { // The world or attached body was destroyed. m_mouseJointId = {}; if ( B3_IS_NON_NULL( m_mouseBodyId ) ) { b3DestroyBody( m_mouseBodyId ); m_mouseBodyId = {}; } } if ( B3_IS_NON_NULL( m_mouseBodyId ) && timeStep > 0.0f ) { b3Body_SetTargetTransform( m_mouseBodyId, { m_mousePoint, b3Quat_identity }, timeStep, true ); } b3World_EnableSleeping( m_worldId, m_context->enableSleep ); b3World_EnableWarmStarting( m_worldId, m_context->enableWarmStarting ); b3World_EnableContinuous( m_worldId, m_context->enableContinuous ); if ( timeStep > 0.0f || m_stepWhilePaused ) { b3World_Step( m_worldId, timeStep, m_context->subStepCount ); } if ( timeStep > 0.0f ) { m_stepCount += 1; m_didStep = true; if ( m_profileWriteIndex - m_profileReadIndex == m_profileCapacity ) { m_profileReadIndex += 1; } m_currentProfileIndex = m_profileWriteIndex & ( m_profileCapacity - 1 ); m_profiles[m_currentProfileIndex] = b3World_GetProfile( m_worldId ); m_profileWriteIndex += 1; } m_triangleIndex = -1; m_userMaterialId = 0; // Cursor pick feeds the surface type readout only. Highlighting is selection driven. if ( m_camera->m_thirdPerson == false ) { PickRay pickRay = m_camera->BuildPickRay( m_context->mouseX, m_context->mouseY ); b3QueryFilter filter = b3DefaultQueryFilter(); filter.name = "pick"; b3RayResult result = b3World_CastRayClosest( m_worldId, pickRay.origin, pickRay.translation, filter ); if ( result.hit ) { if ( b3Shape_GetType( result.shapeId ) == b3_meshShape ) { m_triangleIndex = result.triangleIndex; } m_userMaterialId = result.userMaterialId; } } // The frame latched the origin before Step, but a third person follow moves the eye while // stepping, so refresh it here. Shapes come back demoted to float against this origin, the same // relative frame the translation free view renders, so everything stays precise far from the origin. SetDrawOrigin( m_camera->DrawOrigin() ); b3DebugDraw debugDraw; MakeDebugDraw( &debugDraw ); // Box3D uses this to decide which shapes enter the draw set and lazily fire // createDebugShape. The camera derives it from the view distance, in length units // around the simulation eye, matching the broad-phase tree and the far plane. debugDraw.drawingBounds = m_camera->DrawBounds(); // Same view box drives compound child culling in the adapter. SetViewBounds( debugDraw.drawingBounds ); ApplyGuiFlags( &debugDraw ); b3World_Draw( m_worldId, &debugDraw, B3_DEFAULT_MASK_BITS ); } bool Sample::FocusBounds( b3AABB* ) { return false; } float Sample::InfoPanelWidthEm() const { return INFO_PANEL_WIDTH; } b3BodyId Sample::FocusBody() const { return GetSelectedBody(); } void Sample::FocusHome() { b3AABB aabb = b3World_GetBounds( m_worldId ); float aspect = m_camera->m_height > 0 ? (float)m_camera->m_width / (float)m_camera->m_height : 1.0f; m_camera->Frame( aabb, aspect, 0.75f ); } void Sample::ResetProfile() { m_stepCount = 0; memset( m_profiles, 0, sizeof( m_profiles ) ); m_currentProfileIndex = 0; m_profileReadIndex = 0; m_profileWriteIndex = 0; } b3BodyId Sample::AddGroundBox( float extent ) { b3BodyDef bodyDef = b3DefaultBodyDef(); bodyDef.name = "ground"; bodyDef.position = { 0.0f, -1.0f, 0.0f }; b3BodyId groundId = b3CreateBody( m_worldId, &bodyDef ); b3ShapeDef shapeDef = b3DefaultShapeDef(); b3BoxHull hull = b3MakeBoxHull( extent, 1.0f, extent ); b3ShapeId shapeId = b3CreateHullShape( groundId, &shapeDef, &hull.base ); // Draw the ground with the procedural grid material SetGroundShape( shapeId ); return groundId; } struct RowDef { const char* name; int indent; ImU32 color; }; float AddSegment( ImDrawList* dl, float availWidth, float t, float stepNow, ImU32 col, float x, ImVec2 cursor, float barHeight ) { float w = availWidth * ( t / stepNow ); if ( w > 0.0f ) { dl->AddRectFilled( ImVec2( x, cursor.y ), ImVec2( x + w, cursor.y + barHeight ), col ); x += w; } return x; } // Bottom diagnostics drawer (M). Tabs: Profile, Counters, Renderer, and the // optional ImPlot frame-time chart. Anchored along the window bottom, clear // of the right info panel. void Sample::DrawMetrics() { if ( m_context->showMetrics == false ) { return; } float fontSize = ImGui::GetFontSize(); float menuWidth = InfoPanelWidthEm() * fontSize; float drawerHeight = 16.0f * fontSize; float drawerWidth = m_camera->m_width - menuWidth - 1.5f * fontSize; ImGui::SetNextWindowPos( { 0.5f * fontSize, m_camera->m_height - drawerHeight - 0.5f * fontSize } ); ImGui::SetNextWindowSize( { drawerWidth, drawerHeight } ); ImGui::Begin( "Metrics", nullptr, ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoTitleBar ); if ( ImGui::BeginTabBar( "MetricsTabs", ImGuiTabBarFlags_None ) == false ) { ImGui::End(); return; } if ( ImGui::BeginTabItem( "Profile" ) ) { int count = m_profileWriteIndex - m_profileReadIndex; // Unroll ring buffer into per-field histories. constexpr int kRowCount = 22; float histories[kRowCount][m_profileCapacity]; float totals[kRowCount] = {}; for ( int i = 0; i < count; ++i ) { int idx = ( m_profileReadIndex + i ) & ( m_profileCapacity - 1 ); const b3Profile& p = m_profiles[idx]; histories[0][i] = p.step; histories[1][i] = p.pairs; histories[2][i] = p.collide; histories[3][i] = p.solve; histories[4][i] = p.solverSetup; histories[5][i] = p.constraints; histories[6][i] = p.prepareConstraints; histories[7][i] = p.integrateVelocities; histories[8][i] = p.warmStart; histories[9][i] = p.solveImpulses; histories[10][i] = p.integratePositions; histories[11][i] = p.relaxImpulses; histories[12][i] = p.applyRestitution; histories[13][i] = p.storeImpulses; histories[14][i] = p.splitIslands; histories[15][i] = p.transforms; histories[16][i] = p.jointEvents; histories[17][i] = p.hitEvents; histories[18][i] = p.refit; histories[19][i] = p.sleepIslands; histories[20][i] = p.bullets; histories[21][i] = p.sensors; totals[0] += p.step; totals[1] += p.pairs; totals[2] += p.collide; totals[3] += p.solve; totals[4] += p.solverSetup; totals[5] += p.constraints; totals[6] += p.prepareConstraints; totals[7] += p.integrateVelocities; totals[8] += p.warmStart; totals[9] += p.solveImpulses; totals[10] += p.integratePositions; totals[11] += p.relaxImpulses; totals[12] += p.applyRestitution; totals[13] += p.storeImpulses; totals[14] += p.splitIslands; totals[15] += p.transforms; totals[16] += p.jointEvents; totals[17] += p.hitEvents; totals[18] += p.refit; totals[19] += p.sleepIslands; totals[20] += p.bullets; totals[21] += p.sensors; } // Smoothed over the last few frames so bars don't jitter visibly. constexpr int kNowWindow = 10; float now[kRowCount] = {}; { int n = count < kNowWindow ? count : kNowWindow; if ( n > 0 ) { float inv = 1.0f / n; for ( int r = 0; r < kRowCount; ++r ) { float sum = 0.0f; for ( int i = count - n; i < count; ++i ) { sum += histories[r][i]; } now[r] = sum * inv; } } } // Rolling average float avg[kRowCount] = {}; if ( count > 0 ) { float scale = 1.0f / count; for ( int i = 0; i < kRowCount; ++i ) { avg[i] = scale * totals[i]; } } float rowMax[kRowCount] = {}; for ( int r = 0; r < kRowCount; ++r ) { for ( int i = 0; i < count; ++i ) { if ( histories[r][i] > rowMax[r] ) { rowMax[r] = histories[r][i]; } } } // Match Frame Time chart's first three colors so rows read with the line plot. const ImU32 colorStep = IM_COL32( 102, 153, 255, 255 ); const ImU32 colorPairs = IM_COL32( 220, 220, 220, 255 ); const ImU32 colorCollide = IM_COL32( 255, 140, 51, 255 ); const ImU32 colorSolve = IM_COL32( 102, 204, 102, 255 ); const ImU32 colorSensors = IM_COL32( 200, 120, 220, 255 ); const ImU32 colorOther = IM_COL32( 90, 90, 90, 255 ); const ImU32 colorDefault = IM_COL32( 220, 220, 220, 255 ); const RowDef rows[kRowCount] = { { "step", 0, colorStep }, { "pairs", 0, colorPairs }, { "collide", 0, colorCollide }, { "solve", 0, colorSolve }, { "setup", 1, colorDefault }, { "constraints", 1, colorDefault }, { "prepare", 2, colorDefault }, { "velocities", 2, colorDefault }, { "warm start", 2, colorDefault }, { "bias", 2, colorDefault }, { "positions", 2, colorDefault }, { "relax", 2, colorDefault }, { "restitution", 2, colorDefault }, { "store", 2, colorDefault }, { "split islands", 2, colorDefault }, { "transforms", 1, colorDefault }, { "joint events", 1, colorDefault }, { "hit events", 1, colorDefault }, { "refit BVH", 1, colorDefault }, { "sleep", 1, colorDefault }, { "bullets", 1, colorDefault }, { "sensors", 0, colorSensors }, }; // Derive parent/child links from the indent levels so we can collapse subtrees. int parents[kRowCount]; bool hasChildren[kRowCount] = {}; { int stack[8]; int stackSize = 0; for ( int i = 0; i < kRowCount; ++i ) { while ( stackSize > 0 && rows[stack[stackSize - 1]].indent >= rows[i].indent ) { --stackSize; } parents[i] = stackSize > 0 ? stack[stackSize - 1] : -1; stack[stackSize++] = i; if ( parents[i] >= 0 ) { hasChildren[parents[i]] = true; } } } static bool s_rowOpen[kRowCount]; static bool s_showPlots = false; // Bars are drawn relative to the step row so the proportions are visually consistent. const float stepNow = b3MaxFloat( now[0], 0.001f ); if ( ImGui::Button( "Reset" ) ) { ResetProfile(); } ImGui::SameLine(); ImGui::Checkbox( "Show plots", &s_showPlots ); ImGui::SameLine(); ImGui::Text( " step %.2f ms", now[0] ); // Flame strip: step subdivided by top-level children. { float pairsT = now[1]; float collideT = now[2]; float solveT = now[3]; float sensorsT = now[21]; float otherT = b3MaxFloat( stepNow - pairsT - collideT - solveT - sensorsT, 0.0f ); float availWidth = ImGui::GetContentRegionAvail().x; float barHeight = 1.5f * fontSize; ImDrawList* dl = ImGui::GetWindowDrawList(); ImVec2 cursor = ImGui::GetCursorScreenPos(); float x = cursor.x; x = AddSegment( dl, availWidth, pairsT, stepNow, colorPairs, x, cursor, barHeight ); x = AddSegment( dl, availWidth, collideT, stepNow, colorCollide, x, cursor, barHeight ); x = AddSegment( dl, availWidth, solveT, stepNow, colorSolve, x, cursor, barHeight ); x = AddSegment( dl, availWidth, sensorsT, stepNow, colorSensors, x, cursor, barHeight ); x = AddSegment( dl, availWidth, otherT, stepNow, colorOther, x, cursor, barHeight ); ImGui::Dummy( ImVec2( availWidth, barHeight ) ); } const ImGuiTableFlags tableFlags = ImGuiTableFlags_BordersInnerV | ImGuiTableFlags_RowBg | ImGuiTableFlags_SizingFixedFit | ImGuiTableFlags_ScrollY; const int colCount = s_showPlots ? 6 : 5; ImVec2 tableSize = ImGui::GetContentRegionAvail(); if ( ImGui::BeginTable( "profile", colCount, tableFlags, tableSize ) ) { ImGui::TableSetupColumn( "section", ImGuiTableColumnFlags_WidthFixed, 8.0f * fontSize ); ImGui::TableSetupColumn( "now", ImGuiTableColumnFlags_WidthFixed, 3.0f * fontSize ); ImGui::TableSetupColumn( "avg", ImGuiTableColumnFlags_WidthFixed, 3.0f * fontSize ); ImGui::TableSetupColumn( "max", ImGuiTableColumnFlags_WidthFixed, 3.0f * fontSize ); ImGui::TableSetupColumn( "% step", ImGuiTableColumnFlags_WidthFixed, 8.0f * fontSize ); if ( s_showPlots ) { ImGui::TableSetupColumn( "history", ImGuiTableColumnFlags_WidthFixed, 16.0f * fontSize ); } ImGui::TableHeadersRow(); const float rowHeight = 1.5f * fontSize; for ( int r = 0; r < kRowCount; ++r ) { bool visible = true; for ( int p = parents[r]; p >= 0; p = parents[p] ) { if ( !s_rowOpen[p] ) { visible = false; break; } } if ( !visible ) { continue; } // Hide leaf rows that are entirely zero. Parents stay so structure reads. if ( !hasChildren[r] && now[r] == 0.0f && avg[r] == 0.0f && rowMax[r] == 0.0f ) { continue; } const RowDef& d = rows[r]; const float* hist = histories[r]; ImGui::TableNextRow(); ImGui::TableNextColumn(); if ( d.indent > 0 ) { ImGui::Indent( d.indent * fontSize ); } if ( hasChildren[r] ) { ImGuiTreeNodeFlags flags = ImGuiTreeNodeFlags_OpenOnArrow | ImGuiTreeNodeFlags_OpenOnDoubleClick | ImGuiTreeNodeFlags_NoTreePushOnOpen; ImGui::PushStyleColor( ImGuiCol_Text, d.color ); s_rowOpen[r] = ImGui::TreeNodeEx( d.name, flags ); ImGui::PopStyleColor(); } else { float leafIndent = ImGui::GetTreeNodeToLabelSpacing(); ImGui::Indent( leafIndent ); ImGui::PushStyleColor( ImGuiCol_Text, d.color ); ImGui::TextUnformatted( d.name ); ImGui::PopStyleColor(); ImGui::Unindent( leafIndent ); } if ( d.indent > 0 ) { ImGui::Unindent( d.indent * fontSize ); } ImGui::TableNextColumn(); ImGui::Text( "%6.2f", now[r] ); ImGui::TableNextColumn(); ImGui::Text( "%6.2f", avg[r] ); ImGui::TableNextColumn(); ImGui::Text( "%6.2f", rowMax[r] ); ImGui::TableNextColumn(); float frac = b3ClampFloat( now[r] / stepNow, 0.0f, 1.0f ); ImGui::PushStyleColor( ImGuiCol_PlotHistogram, d.color ); ImGui::ProgressBar( frac, ImVec2( -FLT_MIN, 0.0f ), "" ); ImGui::PopStyleColor(); if ( s_showPlots ) { ImGui::TableNextColumn(); if ( count > 1 ) { char id[16]; snprintf( id, sizeof( id ), "##h%d", r ); ImGui::PushStyleColor( ImGuiCol_PlotLines, d.color ); ImGui::PlotLines( id, hist, count, 0, nullptr, 0.0f, rowMax[r] * 1.05f + 0.001f, ImVec2( -FLT_MIN, rowHeight ) ); ImGui::PopStyleColor(); } } } ImGui::EndTable(); } ImGui::EndTabItem(); } if ( ImGui::BeginTabItem( "Counters" ) ) { ImGui::BeginChild( "##counters_scroll" ); b3Counters s = b3World_GetCounters( m_worldId ); constexpr int colorCount = sizeof( s.colorCounts ) / sizeof( s.colorCounts[0] ); const int overflowIndex = colorCount - 1; constexpr int manifoldBucketCount = sizeof( s.manifoldCounts ) / sizeof( s.manifoldCounts[0] ); // Bars are scaled to the largest non-overflow color so the distribution shape reads clearly; // overflow gets its own bar against the same scale, with a red tint to flag coupling problems. int totalCount = 0; int maxCount = 1; for ( int i = 0; i < colorCount; ++i ) { totalCount += s.colorCounts[i]; if ( i != overflowIndex && s.colorCounts[i] > maxCount ) { maxCount = s.colorCounts[i]; } } int totalManifolds = 0; int maxManifolds = 1; for ( int i = 0; i < manifoldBucketCount; ++i ) { totalManifolds += s.manifoldCounts[i]; if ( s.manifoldCounts[i] > maxManifolds ) { maxManifolds = s.manifoldCounts[i]; } } ImGui::Text( "bodies/shapes/contacts/joints = %d/%d/%d/%d", s.bodyCount, s.shapeCount, s.contactCount, s.jointCount ); { float frac = s.awakeContactCount > 0 ? b3ClampFloat( (float)s.recycledContactCount / (float)s.awakeContactCount, 0.0f, 1.0f ) : 0.0f; char overlay[32]; snprintf( overlay, sizeof( overlay ), "%d / %d", s.recycledContactCount, s.awakeContactCount ); ImGui::TextUnformatted( "recycled contacts" ); ImGui::SameLine(); ImGui::ProgressBar( frac, ImVec2( -FLT_MIN, 0.0f ), overlay ); } ImGui::Text( "islands/tasks = %d/%d", s.islandCount, s.taskCount ); ImGui::Text( "tree height static/movable = %d/%d", s.staticTreeHeight, s.treeHeight ); ImGui::Text( "sat call/hit = %d/%d", s.satCallCount, s.satCacheHitCount ); ImGui::Text( "toi d/p/r = %d/%d/%d", s.distanceIterations, s.pushBackIterations, s.rootIterations ); ImGui::Text( "stack allocator size = %d K", s.stackUsed / 1024 ); ImGui::Text( "arena capacity = %d K", s.arenaCapacity / 1024 ); ImGui::Text( "total allocation = %d K", s.byteCount / 1024 ); ImGui::Separator(); b3Capacity c = b3World_GetMaxCapacity( m_worldId ); ImGui::Text( "max capacities" ); ImGui::BulletText( "static shapes/bodies = %d/%d", c.staticShapeCount, c.staticBodyCount ); ImGui::BulletText( "dynamic shapes/bodies = %d/%d", c.dynamicShapeCount, c.dynamicBodyCount ); ImGui::BulletText( "contacts = %d", c.contactCount ); ImGui::Separator(); ImGui::Text( "%d constraints across %d colors", totalCount, colorCount ); const ImGuiTableFlags tableFlags = ImGuiTableFlags_BordersInnerV | ImGuiTableFlags_RowBg | ImGuiTableFlags_SizingFixedFit; if ( ImGui::BeginTable( "graphColors", 3, tableFlags ) ) { ImGui::TableSetupColumn( "color", ImGuiTableColumnFlags_WidthFixed, 3.5f * fontSize ); ImGui::TableSetupColumn( "count", ImGuiTableColumnFlags_WidthFixed, 5.0f * fontSize ); ImGui::TableSetupColumn( "share", ImGuiTableColumnFlags_WidthFixed, 16.0f * fontSize ); ImGui::TableHeadersRow(); const float invMax = 1.0f / static_cast( maxCount ); for ( int i = 0; i < colorCount; ++i ) { int count = s.colorCounts[i]; bool isOverflow = ( i == overflowIndex ); // Skip empty slots, but always show overflow. if ( count == 0 && !isOverflow ) { continue; } uint32_t hex = static_cast( b3GetGraphColor( i ) ); ImU32 swatch = IM_COL32( ( hex >> 16 ) & 0xFF, ( hex >> 8 ) & 0xFF, hex & 0xFF, 255 ); ImU32 barColor = isOverflow ? IM_COL32( 220, 60, 60, 255 ) : swatch; ImGui::TableNextRow(); ImGui::TableNextColumn(); if ( isOverflow ) { ImGui::PushStyleColor( ImGuiCol_Text, IM_COL32( 220, 60, 60, 255 ) ); ImGui::TextUnformatted( "over" ); ImGui::PopStyleColor(); } else { ImGui::PushStyleColor( ImGuiCol_Text, swatch ); ImGui::Text( "%d", i ); ImGui::PopStyleColor(); } ImGui::TableNextColumn(); ImGui::Text( "%d", count ); ImGui::TableNextColumn(); float frac = b3ClampFloat( count * invMax, 0.0f, 1.0f ); ImGui::PushStyleColor( ImGuiCol_PlotHistogram, barColor ); ImGui::ProgressBar( frac, ImVec2( -FLT_MIN, 0.0f ), "" ); ImGui::PopStyleColor(); } ImGui::EndTable(); } ImGui::Separator(); ImGui::Text( "%d manifolds across %d buckets", totalManifolds, manifoldBucketCount ); if ( ImGui::BeginTable( "manifolds", 3, tableFlags ) ) { ImGui::TableSetupColumn( "manifolds", ImGuiTableColumnFlags_WidthFixed, 3.5f * fontSize ); ImGui::TableSetupColumn( "count", ImGuiTableColumnFlags_WidthFixed, 5.0f * fontSize ); ImGui::TableSetupColumn( "share", ImGuiTableColumnFlags_WidthFixed, 16.0f * fontSize ); ImGui::TableHeadersRow(); const float invMax = 1.0f / static_cast( maxManifolds ); for ( int i = 0; i < manifoldBucketCount; ++i ) { int count = s.manifoldCounts[i]; if ( count == 0 ) { continue; } ImGui::TableNextRow(); ImGui::TableNextColumn(); ImGui::Text( "%d", i + 1 ); ImGui::TableNextColumn(); ImGui::Text( "%d", count ); ImGui::TableNextColumn(); float frac = b3ClampFloat( count * invMax, 0.0f, 1.0f ); ImGui::ProgressBar( frac, ImVec2( -FLT_MIN, 0.0f ), "" ); } ImGui::EndTable(); } ImGui::EndChild(); ImGui::EndTabItem(); } if ( ImGui::BeginTabItem( "Renderer" ) ) { const RenderStats st = GetRenderStats(); // Display-link frame time vs the RenderFrame submit cost, both // EMA-smoothed so the readout doesn't flicker. const float frameMs = (float)( sapp_frame_duration() * 1000.0 ); static float smoothedFrameMs = 0.0f; static float smoothedCpuMs = 0.0f; smoothedFrameMs = ( smoothedFrameMs <= 0.0f ) ? frameMs : smoothedFrameMs * 0.9f + frameMs * 0.1f; smoothedCpuMs = ( smoothedCpuMs <= 0.0f ) ? st.frameTimeMs : smoothedCpuMs * 0.9f + st.frameTimeMs * 0.1f; const float fps = ( smoothedFrameMs > 0.0f ) ? ( 1000.0f / smoothedFrameMs ) : 0.0f; const char* backend = "?"; switch ( sg_query_backend() ) { case SG_BACKEND_D3D11: backend = "D3D11"; break; case SG_BACKEND_METAL_MACOS: backend = "Metal"; break; case SG_BACKEND_GLCORE: backend = "GLCORE"; break; case SG_BACKEND_GLES3: backend = "GLES3"; break; case SG_BACKEND_WGPU: backend = "WGPU"; break; default: break; } ImGui::Text( "%s %.2f ms (%.0f FPS) CPU %.2f ms", backend, smoothedFrameMs, fps, smoothedCpuMs ); ImGui::Text( "draw calls %d (approx)", st.drawCallCount ); ImGui::Separator(); ImGui::Text( "opaque cubes %d spheres %d capsules %d geom %d (%d spans)", st.cubeCount, st.sphereCount, st.capsuleCount, st.geomInstanceCount, st.geomSpanCount ); ImGui::Text( "transp. cubes %d spheres %d capsules %d geom %d", st.cubeCountXp, st.sphereCountXp, st.capsuleCountXp, st.geomInstanceCountXp ); ImGui::Text( "overlays lines %d points %d", st.lineCount, st.pointCount ); ImGui::EndTabItem(); } if ( ImGui::BeginTabItem( "Frame Time" ) ) { float maxValue = 0.0f; float times[m_profileCapacity]; float stepTimes[m_profileCapacity]; float collideTimes[m_profileCapacity]; float solveTimes[m_profileCapacity]; int count = m_profileWriteIndex - m_profileReadIndex; for ( int i = 0; i < count; ++i ) { int index = ( m_profileReadIndex + i ) & ( m_profileCapacity - 1 ); times[i] = i / 60.0f; stepTimes[i] = m_profiles[index].step; collideTimes[i] = m_profiles[index].collide; solveTimes[i] = m_profiles[index].solve; maxValue = b3MaxFloat( stepTimes[i], maxValue ); } ImVec2 plotSize = ImGui::GetContentRegionAvail(); if ( ImPlot::BeginPlot( "Profile", plotSize, ImPlotFlags_NoTitle ) ) { ImPlot::SetupAxes( "t", "ms" ); ImPlot::SetupAxisLimits( ImAxis_X1, 0.0, m_profileCapacity / 60.0 ); ImPlot::SetupAxisLimits( ImAxis_Y1, 0.0, b3MaxFloat( maxValue, 1.0f ) * 1.05, ImPlotCond_Always ); ImPlot::PlotLine( "step", times, stepTimes, count ); ImPlot::PlotLine( "collide", times, collideTimes, count ); ImPlot::PlotLine( "solve", times, solveTimes, count ); ImPlot::EndPlot(); } ImGui::EndTabItem(); } // Sample-specific tabs (the Replay viewer adds Timeline here). DrawMetricsTab(); ImGui::EndTabBar(); ImGui::End(); } void Sample::MouseDown( b3Vec2 p, int button, int modifiers ) { if ( m_camera->m_thirdPerson ) { return; } if ( button == 0 && modifiers == 0 ) { // Plain click selects the body under the cursor, any type. Empty space clears. PickRay pickRay = m_camera->BuildPickRay( p.x, p.y ); b3QueryFilter filter = b3DefaultQueryFilter(); filter.name = "select"; b3RayResult result = b3World_CastRayClosest( m_worldId, pickRay.origin, pickRay.translation, filter ); if ( result.hit ) { SetSelectedBody( b3Shape_GetBody( result.shapeId ) ); } else { ClearSelection(); } } else if ( button == 0 && modifiers == MOD_CTRL ) { // Ctrl click grabs a dynamic body with a kinematic mouse body and a motor joint. PickRay pickRay = m_camera->BuildPickRay( p.x, p.y ); b3QueryFilter filter = b3DefaultQueryFilter(); filter.name = "grab"; b3RayResult result = b3World_CastRayClosest( m_worldId, pickRay.origin, pickRay.translation, filter ); b3BodyId bodyId = result.hit ? b3Shape_GetBody( result.shapeId ) : b3_nullBodyId; if ( result.hit && b3Body_GetType( bodyId ) == b3_dynamicBody ) { m_mousePoint = result.point; b3BodyDef bodyDef = b3DefaultBodyDef(); bodyDef.type = b3_kinematicBody; bodyDef.position = m_mousePoint; bodyDef.enableSleep = false; m_mouseBodyId = b3CreateBody( m_worldId, &bodyDef ); // Grabbing also selects so the body stays highlighted while dragged. SetSelectedBody( bodyId ); b3MotorJointDef jointDef = b3DefaultMotorJointDef(); jointDef.base.bodyIdA = m_mouseBodyId; jointDef.base.bodyIdB = bodyId; jointDef.base.localFrameB.p = b3Body_GetLocalPoint( bodyId, result.point ); jointDef.linearHertz = 7.5f; jointDef.linearDampingRatio = 1.0f; b3MassData massData = b3Body_GetMassData( bodyId ); float g = b3Length( b3World_GetGravity( m_worldId ) ); float mg = massData.mass * g; jointDef.maxSpringForce = m_mouseForceScale * mg; if ( massData.mass > 0.0f ) { // This acts like angular friction float trace = massData.inertia.cx.x + massData.inertia.cy.y + massData.inertia.cz.z; float lever = sqrtf( trace / ( 3.0f * massData.mass ) ); jointDef.maxVelocityTorque = 0.5f * lever * mg; } m_mouseJointId = b3CreateMotorJoint( m_worldId, &jointDef ); b3Body_SetAwake( bodyId, true ); m_mouseFraction = result.fraction; } } else if ( modifiers & MOD_SHIFT ) { PickRay pickRay = m_camera->BuildPickRay( p.x, p.y ); b3Vec3 direction = b3Normalize( pickRay.translation ); b3ShapeDef shapeDef = b3DefaultShapeDef(); if ( modifiers & MOD_CTRL ) { b3BodyDef bodyDef = b3DefaultBodyDef(); bodyDef.type = b3_dynamicBody; bodyDef.position = pickRay.origin + 2.0f * direction; bodyDef.linearVelocity = ( 10.0f * m_launchSpeedScale ) * direction; bodyDef.isBullet = true; b3BodyId bodyId = b3CreateBody( m_worldId, &bodyDef ); b3HullData* hull = b3CreateCylinder( 2.0f, 0.15f, 0.0f, 6 ); b3CreateHullShape( bodyId, &shapeDef, hull ); b3DestroyHull( hull ); } else if ( modifiers & MOD_ALT ) { b3Pos position = pickRay.origin + 2.0f * direction; Human human = {}; CreateHuman( &human, m_worldId, position, 1.0f, 1.0f, 1.0f, 0, nullptr, true ); Human_SetBullet( &human, true ); Human_SetVelocity( &human, ( 10.0f * m_launchSpeedScale ) * direction ); } else { b3BodyDef bodyDef = b3DefaultBodyDef(); bodyDef.type = b3_dynamicBody; bodyDef.position = pickRay.origin + 2.0f * direction; bodyDef.linearVelocity = ( 20.0f * m_launchSpeedScale ) * direction; bodyDef.isBullet = true; b3BodyId bodyId = b3CreateBody( m_worldId, &bodyDef ); b3Sphere sphere = { b3Vec3_zero, 0.25f }; shapeDef.density *= 4.0f; b3CreateSphereShape( bodyId, &shapeDef, &sphere ); } } } void Sample::MouseUp( b3Vec2 p, int button ) { if ( b3Joint_IsValid( m_mouseJointId ) ) { b3DestroyJoint( m_mouseJointId, true ); } if ( b3Body_IsValid( m_mouseBodyId ) ) { b3DestroyBody( m_mouseBodyId ); } m_mouseJointId = b3_nullJointId; m_mouseBodyId = b3_nullBodyId; m_mouseFraction = 0.0f; } void Sample::MouseMove( b3Vec2 p ) { if ( m_camera->m_thirdPerson ) { // The cursor is locked in third person, so look uses the host's // relative deltas. Camera angles are radians here (host camera). m_mouseDelta = { m_context->mouseDX, m_context->mouseDY }; const float sensitivity = 0.1f * B3_DEG_TO_RAD; m_camera->m_yaw -= 2.0f * sensitivity * m_mouseDelta.x; m_camera->m_pitch += sensitivity * m_mouseDelta.y; m_camera->m_pitch = b3ClampFloat( m_camera->m_pitch, -85.0f * B3_DEG_TO_RAD, 85.0f * B3_DEG_TO_RAD ); } PickRay pickRay = m_camera->BuildPickRay( p.x, p.y ); if ( B3_IS_NON_NULL( m_mouseJointId ) ) { m_mousePoint = pickRay.origin + m_mouseFraction * pickRay.translation; } } void Sample::DrawTextLine( const char* text, ... ) { va_list args; va_start( args, text ); char buffer[512]; vsnprintf( buffer, sizeof( buffer ), text, args ); va_end( args ); DrawScreenString( 5, m_textLine, MakeColor( b3_colorWhite ), buffer ); m_textLine += m_textIncrement; } SampleEntry g_sampleEntries[MAX_SAMPLES]; int g_sampleCount = 0; int g_replayIndex = -1; int RegisterSample( const char* category, const char* name, SampleCreateFcn* fcn ) { int index = g_sampleCount; if ( index < MAX_SAMPLES ) { g_sampleEntries[index] = { category, name, fcn }; g_sampleCount += 1; return index; } return -1; } // Like RegisterSample but remembers the index so the Replay menu can switch to the viewer. int RegisterReplay( const char* category, const char* name, SampleCreateFcn* fcn ) { int index = RegisterSample( category, name, fcn ); if ( index >= 0 ) { g_replayIndex = index; } return index; } void SelectSample( SampleContext* context, int selection, bool restart ) { if ( selection < 0 || selection >= g_sampleCount ) { // Out of range: keep the current sample rather than calling a null factory. return; } // delete tolerates the first selection, before any sample exists. delete context->sample; context->sample = nullptr; context->sampleIndex = selection; context->restart = restart; context->sample = g_sampleEntries[selection].CreateFcn( context ); // Fit the shadow cascade range to the world bounds. b3AABB bounds = b3World_GetBounds( context->sample->m_worldId ); float diagonal = b3Distance( bounds.lowerBound, bounds.upperBound ); SetShadowSplitFar( b3ClampFloat( diagonal, SHADOW_SPLIT_FAR, 200.0f ) ); // Samples read restart only while constructing, to keep the camera across a // restart. Clear it so a later switch starts fresh. context->restart = false; } void OpenReplayFileDialog( SampleContext* context ) { if ( g_replayIndex < 0 ) { return; } NFD_Init(); nfdu8char_t* outPath = nullptr; nfdu8filteritem_t filter[1] = { { "Box3D recording", "b3rec" } }; // Start in the working directory, where recordings are saved by default. std::u8string cwd = std::filesystem::current_path().u8string(); const nfdu8char_t* defaultPath = reinterpret_cast( cwd.c_str() ); if ( NFD_OpenDialogU8( &outPath, filter, 1, defaultPath ) == NFD_OKAY ) { snprintf( context->replayFile, sizeof( context->replayFile ), "%s", outPath ); NFD_FreePathU8( outPath ); SelectSample( context, g_replayIndex, false ); } NFD_Quit(); } // Subsequence fuzzy match. Returns a score (higher is better) or -1 if the // needle is not a subsequence of the haystack. Bonuses for a prefix match, a // word start, and a contiguous run, so "stack" ranks Pyramid > Stack sensibly. static int FuzzyScore( const char* needle, const char* haystack ) { if ( needle == nullptr || needle[0] == '\0' ) { return 0; } int score = 0; int hi = 0; int prevMatchHi = -2; for ( int ni = 0; needle[ni] != '\0'; ++ni ) { int nc = tolower( (unsigned char)needle[ni] ); while ( haystack[hi] != '\0' && tolower( (unsigned char)haystack[hi] ) != nc ) { ++hi; } if ( haystack[hi] == '\0' ) { return -1; } int bonus = 1; if ( hi == 0 ) { bonus += 10; // prefix } else if ( !isalnum( (unsigned char)haystack[hi - 1] ) ) { bonus += 5; // word start } if ( hi == prevMatchHi + 1 ) { bonus += 3; // contiguous run } score += bonus; prevMatchHi = hi; ++hi; } return score; } struct Scored { int idx; int score; }; // Filter and rank the sample registry against the picker query. Name matches // outweigh category-only matches. Equal scores keep the sorted registry order. static void RebuildPicker( const char* q, int* outFiltered, int* outCount ) { static Scored scored[MAX_SAMPLES]; int n = 0; for ( int i = 0; i < g_sampleCount; ++i ) { int nameScore = FuzzyScore( q, g_sampleEntries[i].Name ); int catScore = FuzzyScore( q, g_sampleEntries[i].Category ); int best = -1; if ( nameScore >= 0 ) { best = nameScore * 2; } if ( catScore >= 0 && catScore > best ) { best = catScore; } if ( best < 0 ) { continue; } scored[n].idx = i; scored[n].score = best; n += 1; } // Stable insertion sort by score descending. for ( int i = 1; i < n; ++i ) { Scored tmp = scored[i]; int j = i - 1; while ( j >= 0 && scored[j].score < tmp.score ) { scored[j + 1] = scored[j]; --j; } scored[j + 1] = tmp; } for ( int i = 0; i < n; ++i ) { outFiltered[i] = scored[i].idx; } *outCount = n; } static void DrawRow( const char* key, const char* desc ) { ImGui::TableNextRow(); ImGui::TableSetColumnIndex( 0 ); ImGui::TextUnformatted( key ); ImGui::TableSetColumnIndex( 1 ); ImGui::TextUnformatted( desc ); } // b3HexColor (0xRRGGBB) to an opaque ImVec4 for panel text. Distinct from // gfx/utility.h's MakeColor, which returns the renderer's linear Vec4. static ImVec4 HexColor( b3HexColor hexColor ) { uint32_t h = (uint32_t)hexColor; ImU32 color = IM_COL32( ( h >> 16 ) & 0xFF, ( h >> 8 ) & 0xFF, h & 0xFF, 255 ); return ImGui::ColorConvertU32ToFloat4( color ); } // Abbreviated render controls: mostly visualization/debug toggles plus a single // exposure slider. The deep GTAO knobs and most edge/outline parameters keep // their renderer defaults, only the edge convexity coloring is exposed. static void DrawRenderMenu( SampleContext& ctx ) { ImGui::MenuItem( "Shadows", nullptr, &ctx.enableShadows ); if ( ImGui::BeginMenu( "GTAO" ) ) { ImGui::MenuItem( "Enable", nullptr, &ctx.enableGtao ); GtaoTraceParams p = GetGtaoTraceParams(); int quality = p.quality; static const char* qualityNames[] = { "Medium", "High", "Ultra" }; if ( ImGui::Combo( "Quality", &quality, qualityNames, 3 ) ) { p.quality = (AmbientOcclusionQuality)quality; SetGtaoTraceParams( p ); } ImGui::EndMenu(); } bool ibl = GetIblEnabled(); if ( ImGui::MenuItem( "IBL", nullptr, &ibl ) ) { SetIblEnabled( ibl ); } EdgeOverlayParams edgeParams = GetEdgeOverlayParams(); if ( ImGui::MenuItem( "Hull Edges", nullptr, &edgeParams.showHulls ) ) { SetEdgeOverlayParams( &edgeParams ); } if ( ImGui::MenuItem( "Edge Convexity", nullptr, &edgeParams.showEdgeConvexity ) ) { SetEdgeOverlayParams( &edgeParams ); } ImGui::Separator(); ImGui::PushItemWidth( 8.0f * ImGui::GetFontSize() ); float ev = GetExposure(); if ( ImGui::SliderFloat( "Exposure", &ev, -8.0f, 4.0f, "%.2f" ) ) { SetExposure( ev ); } Sun sun = GetSun(); if ( ImGui::SliderFloat( "Sun", &sun.strength, 0.0f, 1.0f, "%.2f" ) ) { SetSun( sun ); } ImGui::PopItemWidth(); ImGui::Separator(); if ( ImGui::BeginMenu( "Debug View" ) ) { static const char* viewNames[] = { "0 - lit", "1 - view-space distance", "2 - CSM cascade", "3 - view-space normal", "4 - raw GTAO", }; for ( int i = 0; i < 5; ++i ) { if ( ImGui::RadioButton( viewNames[i], ctx.debugView == i ) ) { ctx.debugView = i; } } ImGui::EndMenu(); } } static void DrawMenuBar( SampleContext* context ) { b3DebugDraw* gd = GetGuiDraw(); float fontSize = ImGui::GetFontSize(); if ( ImGui::BeginMainMenuBar() ) { if ( ImGui::BeginMenu( "Sim" ) ) { ImGui::MenuItem( "Pause", "P", &context->pause ); if ( ImGui::MenuItem( "Single Step", "O" ) ) { context->singleStep += 1; } if ( ImGui::MenuItem( "Restart", "R" ) ) { SelectSample( context, context->sampleIndex, true ); } ImGui::Separator(); if ( ImGui::MenuItem( "Previous Sample", "[" ) ) { SelectSample( context, b3MaxInt( 0, context->sampleIndex - 1 ), false ); } if ( ImGui::MenuItem( "Next Sample", "]" ) ) { SelectSample( context, b3MinInt( g_sampleCount - 1, context->sampleIndex + 1 ), false ); } ImGui::Separator(); if ( ImGui::MenuItem( "Reset Profile" ) ) { context->sample->ResetProfile(); } if ( ImGui::MenuItem( "Dump Mem Stats" ) ) { b3World_DumpMemoryStats( context->sample->m_worldId ); } ImGui::Separator(); if ( ImGui::MenuItem( "Quit", "Esc" ) ) { sapp_request_quit(); } ImGui::EndMenu(); } if ( ImGui::BeginMenu( "View" ) ) { if ( ImGui::MenuItem( "Hide UI", "Tab" ) ) { context->showUI = false; } if ( ImGui::MenuItem( "Frame Camera" ) ) { b3AABB aabb = b3World_GetBounds( context->sample->m_worldId ); Camera& cam = context->camera; float aspect = cam.m_height > 0 ? (float)cam.m_width / (float)cam.m_height : 1.0f; cam.Frame( aabb, aspect, 0.75f ); } ImGui::MenuItem( "Shapes", nullptr, &gd->drawShapes ); ImGui::MenuItem( "Transparent", nullptr, &context->transparentDynamic ); ImGui::MenuItem( "Joints", nullptr, &gd->drawJoints ); ImGui::MenuItem( "Joint Extras", nullptr, &gd->drawJointExtras ); ImGui::MenuItem( "Bounds", nullptr, &gd->drawBounds ); ImGui::MenuItem( "Mass", nullptr, &gd->drawMass ); ImGui::MenuItem( "Sleep", nullptr, &gd->drawSleep ); ImGui::MenuItem( "Body Names", nullptr, &gd->drawBodyNames ); ImGui::MenuItem( "Graph Colors", nullptr, &gd->drawGraphColors ); ImGui::MenuItem( "Islands", nullptr, &gd->drawIslands ); ImGui::Separator(); ImGui::MenuItem( "Contact Points", nullptr, &gd->drawContacts ); ImGui::MenuItem( "Contact Normals", nullptr, &gd->drawContactNormals ); ImGui::MenuItem( "Contact Features", nullptr, &gd->drawContactFeatures ); ImGui::MenuItem( "Contact Forces", nullptr, &gd->drawContactForces ); if ( ImGui::BeginMenu( "Anchor" ) ) { if ( ImGui::MenuItem( "Anchor A", nullptr, gd->drawAnchorA != 0 ) ) { gd->drawAnchorA = 1; } if ( ImGui::MenuItem( "Anchor B", nullptr, gd->drawAnchorA == 0 ) ) { gd->drawAnchorA = 0; } ImGui::EndMenu(); } ImGui::Separator(); ImGui::MenuItem( "Diagnostics", "M", &context->showMetrics ); ImGui::PushItemWidth( 8.0f * fontSize ); ImGui::SliderFloat( "Draw Distance", &context->drawDistance, 10.0f, Camera::kViewDistance, "%.0f m" ); ImGui::PopItemWidth(); ImGui::Separator(); if ( ImGui::BeginMenu( "Scale" ) ) { ImGui::PushItemWidth( 6.0f * fontSize ); ImGui::InputFloat( "Joint", &gd->jointScale ); ImGui::InputFloat( "Force", &gd->forceScale, 0, 0, "%.6f", ImGuiInputTextFlags_CharsScientific ); ImGui::PopItemWidth(); ImGui::EndMenu(); } ImGui::EndMenu(); } if ( ImGui::BeginMenu( "Render" ) ) { DrawRenderMenu( *context ); ImGui::EndMenu(); } if ( ImGui::BeginMenu( "Samples" ) ) { int i = 0; while ( i < g_sampleCount ) { const char* category = g_sampleEntries[i].Category; if ( ImGui::BeginMenu( category ) ) { while ( i < g_sampleCount && strcmp( category, g_sampleEntries[i].Category ) == 0 ) { bool selected = ( i == context->sampleIndex ); if ( ImGui::MenuItem( g_sampleEntries[i].Name, nullptr, selected ) ) { SelectSample( context, i, false ); } ++i; } ImGui::EndMenu(); } else { while ( i < g_sampleCount && strcmp( category, g_sampleEntries[i].Category ) == 0 ) { ++i; } } } ImGui::EndMenu(); } // Only present once the replay viewer is registered. Open pops a native picker, then hands // the chosen file to the viewer through replayFile. if ( g_replayIndex >= 0 && ImGui::BeginMenu( "Replay" ) ) { if ( ImGui::MenuItem( "Open..." ) ) { // Defer the native picker to the top of the next frame. Running it // here would block on a nested run loop mid-frame (after the buffers // were uploaded and the ImGui frame began), re-entering the render // loop. See OpenReplayFileDialog and SampleContext::openReplayPicker. context->openReplayPicker = true; } ImGui::EndMenu(); } static bool showAbout = false; if ( ImGui::BeginMenu( "Help" ) ) { ImGui::MenuItem( "Controls", "?", &context->showControls ); ImGui::MenuItem( "About", nullptr, &showAbout ); ImGui::EndMenu(); } ImGui::EndMainMenuBar(); if ( context->showControls ) { ImGui::SetNextWindowPos( { context->camera.m_width * 0.5f, context->camera.m_height * 0.35f }, ImGuiCond_Appearing, { 0.5f, 0.5f } ); ImGui::SetNextWindowSize( { 26.0f * fontSize, 0.0f }, ImGuiCond_Appearing ); if ( ImGui::Begin( "Controls", &context->showControls, ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_AlwaysAutoResize ) ) { ImGui::SeparatorText( "Keyboard" ); if ( ImGui::BeginTable( "keys", 2, ImGuiTableFlags_SizingFixedFit ) ) { DrawRow( "Tab", "Show / hide UI" ); DrawRow( "M", "Show / hide diagnostics" ); DrawRow( "P", "Pause / resume" ); DrawRow( "O", "Single step (Shift: 5)" ); DrawRow( "R", "Restart sample" ); DrawRow( "[ ]", "Previous / next sample" ); DrawRow( "Ctrl+O", "Open sample picker" ); DrawRow( "F", "Frame selection / world" ); DrawRow( "?", "Show / hide controls" ); DrawRow( "Esc", "Cancel / close" ); DrawRow( "Ctrl+Q", "Quit" ); ImGui::EndTable(); } ImGui::SeparatorText( "Mouse" ); if ( ImGui::BeginTable( "mouse", 2, ImGuiTableFlags_SizingFixedFit ) ) { DrawRow( "Left click", "Select body/shape" ); DrawRow( "Ctrl + left drag", "Move bodies (mouse joint)" ); DrawRow( "Alt + left drag", "Orbit camera" ); DrawRow( "Alt + middle drag", "Pan camera" ); DrawRow( "Alt + right drag", "Zoom (dolly)" ); DrawRow( "Right drag", "Fly look (WASD to move)" ); DrawRow( "Scroll", "Zoom" ); DrawRow( "Shift + left", "Shoot (Ctrl spin, Alt ragdoll)" ); ImGui::EndTable(); } } ImGui::End(); } if ( showAbout ) { ImGui::SetNextWindowPos( { context->camera.m_width * 0.5f, context->camera.m_height * 0.5f }, ImGuiCond_Appearing, { 0.5f, 0.5f } ); ImGui::SetNextWindowSize( { 22.0f * fontSize, 0.0f }, ImGuiCond_Appearing ); if ( ImGui::Begin( "About", &showAbout, ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_AlwaysAutoResize ) ) { b3Version version = b3GetVersion(); ImGui::Text( "Box3D %d.%d.%d", version.major, version.minor, version.revision ); ImGui::Spacing(); ImGui::TextLinkOpenURL( "github.com/erincatto/box3d", "https://github.com/erincatto/box3d" ); } ImGui::End(); } } } static void DrawSamplePicker( SampleContext* context ) { float fontSize = ImGui::GetFontSize(); // Opens a transient popup; type to filter by name or category, Up/Down to // navigate, Enter to select, Esc / click-outside to dismiss. static char query[64] = {}; static char prevQuery[64] = {}; static int highlight = 0; static int prevHighlight = 0; static int filtered[MAX_SAMPLES]; static int filteredCount = 0; static bool justOpened = false; static bool forceScroll = false; if ( context->openSamplePicker ) { ImGui::OpenPopup( "##sample_picker" ); context->openSamplePicker = false; query[0] = '\0'; prevQuery[0] = '\0'; highlight = 0; prevHighlight = 0; RebuildPicker( query, filtered, &filteredCount ); justOpened = true; forceScroll = true; } ImGui::SetNextWindowPos( { context->camera.m_width * 0.5f, context->camera.m_height * 0.35f }, ImGuiCond_Appearing, { 0.5f, 0.5f } ); ImGui::SetNextWindowSize( { 32.0f * fontSize, 0.0f }, ImGuiCond_Appearing ); if ( ImGui::BeginPopup( "##sample_picker", ImGuiWindowFlags_AlwaysAutoResize | ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_NoSavedSettings ) ) { if ( justOpened ) { ImGui::SetKeyboardFocusHere(); justOpened = false; } ImGui::PushItemWidth( -1.0f ); ImGui::InputTextWithHint( "##q", "Search by name or category...", query, sizeof( query ) ); ImGui::PopItemWidth(); if ( strcmp( query, prevQuery ) != 0 ) { RebuildPicker( query, filtered, &filteredCount ); strncpy( prevQuery, query, sizeof( prevQuery ) ); prevQuery[sizeof( prevQuery ) - 1] = '\0'; highlight = 0; forceScroll = true; } if ( filteredCount > 0 ) { if ( ImGui::IsKeyPressed( ImGuiKey_DownArrow, true ) ) { highlight = ( highlight + 1 ) % filteredCount; } if ( ImGui::IsKeyPressed( ImGuiKey_UpArrow, true ) ) { highlight = ( highlight + filteredCount - 1 ) % filteredCount; } } bool commit = ImGui::IsKeyPressed( ImGuiKey_Enter, false ) || ImGui::IsKeyPressed( ImGuiKey_KeypadEnter, false ); ImGui::BeginChild( "##results", { 0.0f, 14.0f * fontSize }, ImGuiChildFlags_Borders ); for ( int row = 0; row < filteredCount; ++row ) { int i = filtered[row]; char label[160]; snprintf( label, sizeof( label ), "%s > %s", g_sampleEntries[i].Category, g_sampleEntries[i].Name ); bool sel = ( row == highlight ); if ( ImGui::Selectable( label, sel ) ) { highlight = row; commit = true; } if ( sel && ( forceScroll || highlight != prevHighlight ) ) { ImGui::SetScrollHereY(); } } ImGui::EndChild(); prevHighlight = highlight; forceScroll = false; if ( commit && filteredCount > 0 ) { SelectSample( context, filtered[highlight], false ); ImGui::CloseCurrentPopup(); } // The active search field eats the first Esc, so dismiss here instead of // relying on the popup's default nav close. if ( ImGui::IsKeyPressed( ImGuiKey_Escape, false ) ) { ImGui::CloseCurrentPopup(); } ImGui::EndPopup(); } } static void DrawInfoPanel( SampleContext* context ) { const SampleEntry& entry = g_sampleEntries[context->sampleIndex]; float fontSize = ImGui::GetFontSize(); float menuWidth = context->sample->InfoPanelWidthEm() * fontSize; float menuBarHeight = ImGui::GetFrameHeight(); // Full-height panel pinned under the menu bar at the right edge, matching Box2D. ImGui::SetNextWindowPos( { context->camera.m_width - menuWidth - 0.5f * fontSize, menuBarHeight + 0.5f * fontSize } ); ImGui::SetNextWindowSize( { menuWidth, context->camera.m_height - menuBarHeight - fontSize } ); ImGui::Begin( "Info", nullptr, ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoTitleBar ); ImGui::TextColored( HexColor( b3_colorGoldenRod ), "%s", entry.Name ); ImGui::TextColored( HexColor( b3_colorLightGray ), "%s", entry.Category ); ImGui::Separator(); if ( context->pause ) { ImGui::TextColored( HexColor( b3_colorRed ), "PAUSED" ); ImGui::SameLine(); ImGui::TextDisabled( "(p)" ); ImGui::Separator(); } const float frameMs = (float)( sapp_frame_duration() * 1000.0 ); ImGui::TextColored( HexColor( b3_colorSeaGreen ), "%.1f ms", frameMs ); ImGui::TextColored( HexColor( b3_colorSeaGreen ), "step %d", context->sample->m_stepCount ); ImGui::Separator(); // The camera lives in display space, so pivot, radius and speed are in meters // regardless of the simulation's length units. b3Pos p = context->camera.m_pivot; ImGui::TextColored( HexColor( b3_colorSeaGreen ), "pivot m (%.1f, %.1f, %.1f)", p.x, p.y, p.z ); float yawDeg = B3_RAD_TO_DEG * context->camera.m_yaw; float pitchDeg = B3_RAD_TO_DEG * context->camera.m_pitch; ImGui::TextColored( HexColor( b3_colorSeaGreen ), "yaw/pitch (%.1f, %.1f)", yawDeg, pitchDeg ); ImGui::TextColored( HexColor( b3_colorSeaGreen ), "radius m %.1f, speed m/s %.1f", context->camera.m_radius, context->camera.m_speed ); ImGui::Separator(); ImGui::PushItemWidth( 6.0f * fontSize ); if ( context->sample->DrawControls() ) { ImGui::Separator(); } ImGui::PopItemWidth(); if ( context->sample->HasSolverControls() && ImGui::CollapsingHeader( "Solver", ImGuiTreeNodeFlags_DefaultOpen ) ) { ImGui::PushItemWidth( 6.0f * fontSize ); ImGui::SliderInt( "Sub-steps##Solver", &context->subStepCount, 1, 50 ); ImGui::SliderFloat( "Hertz##Solver", &context->hertz, 5.0f, 240.0f, "%.0f hz" ); if ( ImGui::SliderInt( "Workers##Solver", &context->workerCount, 1, B3_MAX_WORKERS ) ) { context->workerCount = b3ClampInt( context->workerCount, 1, B3_MAX_WORKERS ); SelectSample( context, context->sampleIndex, true ); } float recyclingCentimeters = 100.0f * context->recycleDistance; if ( ImGui::SliderFloat( "Recycle##Solver", &recyclingCentimeters, 0.0f, 10.0f, "%.1f cm" ) ) { context->recycleDistance = 0.01f * recyclingCentimeters; b3World_SetContactRecycleDistance( context->sample->m_worldId, context->recycleDistance ); } ImGui::PopItemWidth(); ImGui::Checkbox( "Sleep##Solver", &context->enableSleep ); ImGui::Checkbox( "Warm Starting##Solver", &context->enableWarmStarting ); ImGui::Checkbox( "Continuous##Solver", &context->enableContinuous ); if ( ImGui::Shortcut( ImGuiKey_R ) || ImGui::Button( "Restart" ) ) { SelectSample( context, context->sampleIndex, true ); } } if ( context->sample->HasSolverControls() && ImGui::CollapsingHeader( "Recording", ImGuiTreeNodeFlags_DefaultOpen ) ) { ImGui::PushItemWidth( 9.0f * fontSize ); ImGui::InputText( "File##Recording", context->recordingFile, sizeof( context->recordingFile ) ); ImGui::PopItemWidth(); if ( context->sample->m_recording == nullptr ) { // Restart to a clean world then snapshot it at step 0, a whole session capture. if ( ImGui::Button( "Record (restart)##Recording" ) ) { SelectSample( context, context->sampleIndex, true ); context->sample->StartRecording(); } // Snapshot the running world and log from here, the mid simulation case. if ( ImGui::Button( "Record Now##Recording" ) ) { context->sample->StartRecording(); } } else { if ( ImGui::Button( "Stop##Recording" ) ) { context->sample->FinishRecording(); } ImGui::TextColored( HexColor( b3_colorSeaGreen ), "recording (from step %d)", context->sample->m_recordStartStep ); } } ImGui::End(); } // Minimal in-world HUD shown only when the UI is hidden: sample identity at the // top-left, frame time and step count at the bottom-left. Matches Box2D DrawHud. static void DrawHud( SampleContext* context ) { const SampleEntry& entry = g_sampleEntries[context->sampleIndex]; float fontSize = ImGui::GetFontSize(); DrawScreenStringFormat( 5, (int)( 1.5f * fontSize ), MakeColor( b3_colorYellow ), "%s : %s", entry.Category, entry.Name ); if ( context->pause ) { DrawScreenString( 5, (int)( 3.0f * fontSize ), MakeColor( b3_colorRed ), "****PAUSED****" ); } DrawScreenStringFormat( 5, context->camera.m_height - (int)( 1.5f * fontSize ), MakeColor( b3_colorSeaGreen ), "%.1f ms step %d", 1000.0f * (float)sapp_frame_duration(), context->sample->m_stepCount ); } void DrawUI( SampleContext* context ) { // Hidden shows only the minimal in-world HUD. if ( context->showUI == false ) { DrawHud( context ); return; } DrawMenuBar( context ); DrawSamplePicker( context ); DrawInfoPanel( context ); // Extra top-level windows (the Replay viewer adds the Outline panel). context->sample->DrawSampleWindows(); // Bottom diagnostics drawer. Sample controls live in the info panel. context->sample->DrawMetrics(); } void Sample::ToggleThirdPerson() { if ( m_camera->m_thirdPerson ) { sapp_lock_mouse( false ); m_camera->m_thirdPerson = false; } else { sapp_lock_mouse( true ); m_camera->m_thirdPerson = true; ClearSelection(); } } float CastClosestCallback( b3ShapeId shapeId, b3Pos point, b3Vec3 normal, float fraction, uint64_t materialId, int triangleIndex, int childIndex, void* context ) { CastClosestContext* rayContext = (CastClosestContext*)context; rayContext->shapeId = shapeId; rayContext->point = point; rayContext->normal = normal; rayContext->fraction = fraction; rayContext->materialId = materialId; rayContext->childIndex = childIndex; rayContext->triangleIndex = triangleIndex; rayContext->hit = true; return fraction; } static bool MoverFilterCallback( b3ShapeId shapeId, void* context ) { CharacterMover* self = (CharacterMover*)context; for ( int i = 0; i < self->m_ignoreCount; ++i ) { if ( B3_ID_EQUALS( shapeId, self->m_ignoreShapeIds[i] ) ) { return false; } } return true; } void CharacterMover::Initialize( Sample* sample, b3Pos position ) { m_sample = sample; m_transform.p = position; m_transform.q = b3Quat_identity; m_velocity = { 0.0f, 0.0f, 0.0f }; m_capsule = { { 0.0f, -0.5f, 0.0f }, { 0.0f, 0.5f, 0.0f }, 0.3f }; m_planeCount = 0; m_totalIterations = 0; m_pogoVelocity = 0.0f; m_onGround = false; m_sprint = false; m_ignoreShapeIds = nullptr; m_ignoreCount = 0; } static bool PlaneResultFcn( b3ShapeId shapeId, const b3PlaneResult* planeResults, int planeCount, void* context ) { if ( MoverFilterCallback( shapeId, context ) == false ) { // ignore these planes but continue looking for more return true; } CharacterMover* self = static_cast( context ); float maxPush = FLT_MAX; bool clipVelocity = true; MoverShapeUserData* userData = static_cast( (void*)b3Shape_GetUserData( shapeId ) ); if ( userData != nullptr ) { maxPush = userData->maxPush; clipVelocity = userData->clipVelocity; } for ( int i = 0; i < planeCount && self->m_planeCount < CharacterMover::m_planeCapacity; ++i ) { assert( b3IsValidPlane( planeResults[i].plane ) ); self->m_planes[self->m_planeCount] = { .plane = planeResults[i].plane, .pushLimit = maxPush, .push = 0.0f, .clipVelocity = clipVelocity, }; self->m_planeExtras[self->m_planeCount] = { .point = b3OffsetPos( self->m_transform.p, planeResults[i].point ), .shapeId = shapeId, }; self->m_planeCount += 1; } return true; } void CharacterMover::SolveMove( float timeStep, b3Vec3 forward, b3Vec3 right, b3Vec2 throttle, bool clipVelocity ) { // Friction float speed = b3Length( m_velocity ); if ( speed < m_minSpeed ) { m_velocity.x = 0.0f; m_velocity.y = 0.0f; } else { // Linear damping above stopSpeed and fixed reduction below stopSpeed float control = speed < m_stopSpeed ? m_stopSpeed : speed; // friction has units of 1/time float drop = control * m_friction * timeStep; float newSpeed = b3MaxFloat( 0.0f, speed - drop ); m_velocity *= newSpeed / speed; } float maxSpeed = m_sprint ? 1.5f * m_maxSpeed : m_maxSpeed; b3Vec3 desiredVelocity = maxSpeed * throttle.x * forward + maxSpeed * throttle.y * right; float desiredSpeed; b3Vec3 desiredDirection = b3GetLengthAndNormalize( &desiredSpeed, desiredVelocity ); if ( desiredSpeed > maxSpeed ) { desiredVelocity *= maxSpeed / desiredSpeed; desiredSpeed = maxSpeed; } if ( m_onGround ) { m_velocity.y = 0.0f; } // Accelerate float currentSpeed = b3Dot( m_velocity, desiredDirection ); float addSpeed = desiredSpeed - currentSpeed; if ( addSpeed > 0.0f ) { float accelSpeed = m_accelerate * maxSpeed * timeStep; if ( accelSpeed > addSpeed ) { accelSpeed = addSpeed; } m_velocity += accelSpeed * desiredDirection; } m_velocity.y -= m_gravity * timeStep; b3WorldId worldId = m_sample->m_worldId; float pogoRestLength = 3.0f * m_capsule.radius; float rayLength = pogoRestLength + m_capsule.radius; b3Pos rayOrigin = b3TransformWorldPoint( m_transform, m_capsule.center1 ); b3Vec3 rayTranslation = -rayLength * b3Vec3_axisY; b3QueryFilter skipTeamFilter = { 1, ~2u }; skipTeamFilter.name = "pogo"; b3RayResult rayResult = b3World_CastRayClosest( worldId, rayOrigin, rayTranslation, skipTeamFilter ); if ( rayResult.hit == false ) { m_onGround = false; m_pogoVelocity = 0.0f; DrawLine( rayOrigin, b3OffsetPos( rayOrigin, rayTranslation ), MakeColor( b3_colorGray ) ); } else { m_onGround = true; float pogoCurrentLength = rayResult.fraction * rayLength; float zeta = 0.7f; float hertz = 4.0f; float omega = 2.0f * B3_PI * hertz; float omegaH = omega * timeStep; m_pogoVelocity = ( m_pogoVelocity - omega * omegaH * ( pogoCurrentLength - pogoRestLength ) ) / ( 1.0f + 2.0f * zeta * omegaH + omegaH * omegaH ); DrawLine( rayOrigin, rayResult.point, MakeColor( b3_colorGreen ) ); } b3Pos startPosition = m_transform.p; b3Pos target = m_transform.p + timeStep * m_velocity + timeStep * m_pogoVelocity * b3Vec3_axisY; // Want the mover to collide with allies b3QueryFilter moverFilter = { .categoryBits = 1, .maskBits = ~0u, .id = 1, .name = "mover_collide" }; // The cast should ignore allies b3QueryFilter castFilter = { .categoryBits = 1, .maskBits = ~2u, .id = 1, .name = "mover_cast" }; m_totalIterations = 0; float tolerance = 0.01f; for ( int iteration = 0; iteration < 5; ++iteration ) { m_planeCount = 0; b3Capsule mover; mover.center1 = m_capsule.center1; mover.center2 = m_capsule.center2; mover.radius = m_capsule.radius; b3World_CollideMover( worldId, m_transform.p, &mover, moverFilter, PlaneResultFcn, this ); b3Vec3 targetDelta = target - m_transform.p; b3PlaneSolverResult result = b3SolvePlanes( targetDelta, m_planes, m_planeCount ); m_totalIterations += result.iterationCount; b3Vec3 delta = result.delta; float fraction = b3World_CastMover( worldId, m_transform.p, &mover, delta, castFilter, MoverFilterCallback, this ); delta *= fraction; m_transform.p = m_transform.p + delta; if ( b3LengthSquared( delta ) < tolerance * tolerance ) { break; } } for ( int i = 0; i < m_planeCount; ++i ) { b3BodyId bodyId = b3Shape_GetBody( m_planeExtras[i].shapeId ); b3BodyType bodyType = b3Body_GetType( bodyId ); if ( bodyType != b3_dynamicBody ) { continue; } b3Pos point = m_planeExtras[i].point; b3Vec3 normal = b3Neg( m_planes[i].plane.normal ); float invMassA = 0.0f; float invMassB = b3Body_GetInverseMass( bodyId ); b3Matrix3 invIB = b3Body_GetWorldInverseRotationalInertia( bodyId ); b3Pos pB = b3Body_GetWorldCenter( bodyId ); b3Vec3 rB = b3SubPos( point, pB ); b3Vec3 rnB = b3Cross( rB, normal ); float kNormal = invMassA + invMassB + b3Dot( rnB, b3MulMV( invIB, rnB ) ); float normalMass = kNormal > 0.0f ? 1.0f / kNormal : 0.0f; b3Vec3 vB = b3Body_GetLinearVelocity( bodyId ); b3Vec3 omegaB = b3Body_GetAngularVelocity( bodyId ); b3Vec3 vrB = b3Add( vB, b3Cross( omegaB, rB ) ); float vn = b3Dot( b3Sub( vrB, m_velocity ), normal ); float impulse = b3MaxFloat( -normalMass * vn, 0.0f ); b3Vec3 P = b3MulSV( impulse, normal ); m_velocity = b3MulSub( m_velocity, invMassA, P ); b3Body_ApplyLinearImpulse( bodyId, P, point, true ); } if ( clipVelocity ) { // Using the velocity clipper can avoid picking up velocity from depenetration. // This allows the mover to avoid velocity from soft collision depenetration. m_velocity = b3ClipVector( m_velocity, m_planes, m_planeCount ); } else if ( timeStep > 0.0f ) { // Using the position delta is more holistic and intuitive in some cases. m_velocity = ( 1.0f / timeStep ) * ( m_transform.p - startPosition ); } } void CharacterMover::Step( b3ShapeId* ignoreShapes, int ignoreCount, bool clipVelocity ) { m_ignoreShapeIds = ignoreShapes; m_ignoreCount = ignoreCount; b3Vec2 throttle = { 0.0f, 0.0f }; b3Vec3 forward = -m_sample->m_camera->GetForward(); b3Vec3 right = m_sample->m_camera->GetRight(); forward.y = 0.0f; if ( m_sample->m_camera->m_thirdPerson ) { if ( IsKeyDown( KEY_W ) ) { throttle.x += 1.0f; } if ( IsKeyDown( KEY_S ) ) { throttle.x -= 1.0f; } if ( IsKeyDown( KEY_A ) ) { throttle.y -= 1.0f; } if ( IsKeyDown( KEY_D ) ) { throttle.y += 1.0f; } if ( IsKeyDown( KEY_SPACE ) && m_onGround == true ) { m_velocity.y = m_jumpSpeed; m_onGround = false; } if ( m_onGround == true ) { m_sprint = IsKeyDown( KEY_LEFT_SHIFT ); } else { m_sprint = false; } } float hertz = m_sample->m_context->hertz; float timeStep = hertz > 0.0f ? 1.0f / hertz : 0.0f; // throttle = { 0.0f, 0.0f, -1.0f }; SolveMove( timeStep, forward, right, throttle, clipVelocity ); b3Pos position = m_transform.p; // Follow the mover and latch the draw origin before drawing, so the overlays below demote // against the same eye the view renders from. if ( m_sample->m_camera->m_thirdPerson ) { m_sample->m_camera->m_pivot = position; m_sample->m_camera->UpdateTransform(); } SetDrawOrigin( m_sample->m_camera->DrawOrigin() ); int count = m_planeCount; for ( int i = 0; i < count; ++i ) { b3Plane plane = m_planes[i].plane; b3Pos p1 = position + ( plane.offset - m_capsule.radius ) * plane.normal; b3Pos p2 = p1 + 0.1f * plane.normal; DrawPoint( p1, 5.0f, MakeColor( b3_colorYellow ) ); DrawLine( p1, p2, MakeColor( b3_colorYellow ) ); } DrawSolidCapsule( m_transform, m_capsule, MakeColor( b3_colorBlue ) ); DrawLine( position, position + m_velocity, MakeColor( b3_colorPurple ) ); m_ignoreShapeIds = nullptr; m_ignoreCount = 0; }