sokol-luajit/projects/box3d/sample.cpp

2398 lines
69 KiB
C++

// 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 <ctype.h>
#include <filesystem>
#include <nfd.h>
#include <stdarg.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#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<int>( 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<float>( 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<uint32_t>( 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<float>( 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<const nfdu8char_t*>( 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<CharacterMover*>( context );
float maxPush = FLT_MAX;
bool clipVelocity = true;
MoverShapeUserData* userData = static_cast<MoverShapeUserData*>( (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;
}