M4: PhysicsDemo — the "one small game, end to end"

samples/PhysicsDemo ties physics, audio, input, and rendering together
through nothing but the kernel's own vocabulary — no plugin here
references another plugin's implementation, only Contracts. Press Space
to drop a box; engine.physics simulates it falling onto a static ground;
CubeRenderer (new — engine.render's QuadRenderer drew flat cards, no good
for a physics demo where a BoxCollider needs to actually look like a box)
draws it; physics-demo-game watches each spawned box's own Y velocity via
IPhysicsService and plays a bounce sound via IAudioService the moment a
real fall settles. A looping ambient track plays throughout via
AudioSource's own PlayOnAwake. project.json deliberately excludes
engine.editor — this is the shippable configuration M4's "done when"
actually asks for, not the dev one.

"Landed" isn't a Box3D contact event — the native shim never exposed one
(nothing but scalars crosses that boundary, see lingua_physics.c). Watching
velocity every frame is the honest, right-sized alternative for a demo
this size, not a shortcut around missing infrastructure.

Verified two ways. First, real Space-key input isn't simulable here (no
xdotool/ydotool under this Wayland session) — so PhysicsDemoGame.Tests
drives the actual PhysicsDemoGamePlugin.Configure/Tick through the real
Schedule with a controllable fake IEngineInput (and fake IPhysicsService/
IAudioService, since engine.physics/engine.audio's own correctness is
already covered elsewhere): spawn-on-press with edge detection, the
Rigidbody/BoxCollider/CubeRenderer combo the spawned box actually gets,
and — the case most likely to be subtly wrong — a box that never actually
falls doesn't false-positive as "landed," only one that fell past
FallingThreshold and then settled does, exactly once. All 5 passed
immediately. Second, a real windowed run: a box placed in scene.json above
the ground visibly falls and settles onto it on screen after a real
few-second wait — screenshotting by --screenshot-after-frames alone turned
out not to prove this (VSync is off, so frames race by far faster than
real physics time passes; enough elapsed frames isn't enough elapsed
seconds), an interactive run with a real sleep before the screenshot
command is what actually shows it.

Full suite: 92 tests. Remaining for M4: the Linux + Windows build pipeline.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N1qPfzq8TDCUMFMV3UwV5N
This commit is contained in:
Emil
2026-09-02 17:47:15 +03:00
co-authored by Claude Sonnet 5
parent 88e0afa46b
commit 2d0168104d
15 changed files with 566 additions and 6 deletions
@@ -12,11 +12,14 @@ namespace Engine.Render;
/// <summary>
/// M3's render pipeline: a real perspective camera, drawing every
/// GameObject with a QuadRenderer at its own WorldMatrix — upgraded from
/// M2's single hardcoded NDC-space quad specifically because gizmos need
/// real 3D geometry and a real camera to mean anything (a handle dragged
/// in screen space has to map onto an actual 3D axis). See M3 in
/// docs/kernel-contract.md §8.
/// GameObject with a QuadRenderer or CubeRenderer at its own WorldMatrix —
/// upgraded from M2's single hardcoded NDC-space quad specifically because
/// gizmos need real 3D geometry and a real camera to mean anything (a
/// handle dragged in screen space has to map onto an actual 3D axis). See
/// M3 in docs/kernel-contract.md §8. CubeRenderer itself is M4's addition,
/// for the same reason M3's gizmo needed a real camera: a BoxCollider
/// falling and settling only reads as a physics object if it looks like
/// one, not a flat card.
///
/// GameObject.WorldMatrix and this plugin's own matrices are both
/// System.Numerics.Matrix4x4, which is row-vector (v' = v * M, and
@@ -95,11 +98,64 @@ public sealed class RenderPlugin : IPlugin
0.5f, 0.5f, 0f, 1f, 1f,
];
// Unit cube (1x1x1 before LocalScale), centered at its own origin — see
// CubeRenderer's own doc comment for why this exists (M4's physics
// demo needs falling BoxColliders to actually look like boxes). No
// face culling is enabled anywhere in this plugin, so winding order
// doesn't matter here the way it would with CullFace on.
private static readonly float[] CubeVertices =
[
// position uv
-0.5f, -0.5f, 0.5f, 0f, 0f, // front (+Z)
0.5f, -0.5f, 0.5f, 1f, 0f,
0.5f, 0.5f, 0.5f, 1f, 1f,
0.5f, 0.5f, 0.5f, 1f, 1f,
-0.5f, 0.5f, 0.5f, 0f, 1f,
-0.5f, -0.5f, 0.5f, 0f, 0f,
0.5f, -0.5f, -0.5f, 0f, 0f, // back (-Z)
-0.5f, -0.5f, -0.5f, 1f, 0f,
-0.5f, 0.5f, -0.5f, 1f, 1f,
-0.5f, 0.5f, -0.5f, 1f, 1f,
0.5f, 0.5f, -0.5f, 0f, 1f,
0.5f, -0.5f, -0.5f, 0f, 0f,
-0.5f, -0.5f, -0.5f, 0f, 0f, // left (-X)
-0.5f, -0.5f, 0.5f, 1f, 0f,
-0.5f, 0.5f, 0.5f, 1f, 1f,
-0.5f, 0.5f, 0.5f, 1f, 1f,
-0.5f, 0.5f, -0.5f, 0f, 1f,
-0.5f, -0.5f, -0.5f, 0f, 0f,
0.5f, -0.5f, 0.5f, 0f, 0f, // right (+X)
0.5f, -0.5f, -0.5f, 1f, 0f,
0.5f, 0.5f, -0.5f, 1f, 1f,
0.5f, 0.5f, -0.5f, 1f, 1f,
0.5f, 0.5f, 0.5f, 0f, 1f,
0.5f, -0.5f, 0.5f, 0f, 0f,
-0.5f, 0.5f, 0.5f, 0f, 0f, // top (+Y)
0.5f, 0.5f, 0.5f, 1f, 0f,
0.5f, 0.5f, -0.5f, 1f, 1f,
0.5f, 0.5f, -0.5f, 1f, 1f,
-0.5f, 0.5f, -0.5f, 0f, 1f,
-0.5f, 0.5f, 0.5f, 0f, 0f,
-0.5f, -0.5f, -0.5f, 0f, 0f, // bottom (-Y)
0.5f, -0.5f, -0.5f, 1f, 0f,
0.5f, -0.5f, 0.5f, 1f, 1f,
0.5f, -0.5f, 0.5f, 1f, 1f,
-0.5f, -0.5f, 0.5f, 0f, 1f,
-0.5f, -0.5f, -0.5f, 0f, 0f,
];
private GL? _gl;
private IEngineWindow? _window;
private CameraService? _camera;
private uint _vao;
private uint _vbo;
private uint _cubeVao;
private uint _cubeVbo;
private uint _program;
private uint _texture;
private int _modelLocation;
@@ -143,6 +199,20 @@ public sealed class RenderPlugin : IPlugin
_gl.VertexAttribPointer(1, 2, VertexAttribPointerType.Float, false, stride, (void*)(3 * sizeof(float)));
_gl.EnableVertexAttribArray(1);
_gl.BindVertexArray(0);
_cubeVao = _gl.GenVertexArray();
_gl.BindVertexArray(_cubeVao);
_cubeVbo = _gl.GenBuffer();
_gl.BindBuffer(BufferTargetARB.ArrayBuffer, _cubeVbo);
_gl.BufferData<float>(BufferTargetARB.ArrayBuffer, CubeVertices, BufferUsageARB.StaticDraw);
_gl.VertexAttribPointer(0, 3, VertexAttribPointerType.Float, false, stride, (void*)0);
_gl.EnableVertexAttribArray(0);
_gl.VertexAttribPointer(1, 2, VertexAttribPointerType.Float, false, stride, (void*)(3 * sizeof(float)));
_gl.EnableVertexAttribArray(1);
_gl.BindVertexArray(0);
_gl.Enable(EnableCap.DepthTest);
@@ -159,7 +229,7 @@ public sealed class RenderPlugin : IPlugin
ctx.Events.Subscribe(_onTextureReloaded);
ctx.Services.Provide<IScreenCapture>(new GlScreenCapture(_gl, _window));
ctx.Schedule.Add(Stage.Render, Draw).Reads<QuadRenderer>();
ctx.Schedule.Add(Stage.Render, Draw).Reads<QuadRenderer>().Reads<CubeRenderer>();
ctx.Schedule.Add(Stage.Present, Present);
ctx.Log.Info("GL context created, 3D quad pipeline ready");
}
@@ -176,6 +246,8 @@ public sealed class RenderPlugin : IPlugin
_gl.DeleteTexture(_texture);
_gl.DeleteVertexArray(_vao);
_gl.DeleteBuffer(_vbo);
_gl.DeleteVertexArray(_cubeVao);
_gl.DeleteBuffer(_cubeVbo);
_gl.DeleteProgram(_program);
_gl.Dispose();
}
@@ -237,6 +309,13 @@ public sealed class RenderPlugin : IPlugin
SetMatrix(_modelLocation, go.WorldMatrix);
_gl.DrawArrays(PrimitiveType.Triangles, 0, 6);
}
_gl.BindVertexArray(_cubeVao);
foreach (var go in world.Query<CubeRenderer>())
{
SetMatrix(_modelLocation, go.WorldMatrix);
_gl.DrawArrays(PrimitiveType.Triangles, 0, 36);
}
}
// Split from Draw() into its own Stage.Present system so engine.editor