How to put embedded-3dgfx inside an application you already have.
The engine has no run(). It is a library of stages you call: you own the
loop, the buffers, and the clock. This guide walks the six seams where your code
and the engine meet, in the order you will hit them.
Every snippet below has a complete, runnable counterpart in
examples/integration_*.rs — see the example index
at the end.
You provide:
| You own | Type | Notes |
|---|---|---|
| Framebuffer | any DrawTarget<Color = Rgb565> + OriginDimensions |
your panel, your &mut [Rgb565], or the simulator |
| Depth buffer | &mut [ZDepth] |
exactly width * height entries |
| Command buffer | CommandBuffer<MAX> |
heapless; capacity is primitives, not bytes |
You call, per frame:
clear → record → execute → present
Everything the frame loop touches comes from the prelude:
use embedded_3dgfx::prelude::*;That single import covers the frame driver (K3dengine), the buffers
(CommandBuffer, FrameCtx, ZDepth, Z_MAX_VALUE), geometry (Geometry,
K3dMesh, RenderMode, Camera), primitives (DrawPrimitive), per-pass
configuration (RasterState, draw_zbuffered_with_state, FogConfig,
DitherConfig, apply_default_caps), the error taxonomy (RenderError,
BudgetKind, DisplayError) and the stage markers (Stage, StageKind).
Feature-gated types (PointLight, Texture / TextureManager) join it when the
feature is enabled.
Writing a display backend needs exactly one more import. The output stage gathers its own vocabulary, so a driver never reaches into four submodules:
use embedded_3dgfx::pipeline::output::{
DMACapableFrameBufferBackend, DisplayBackend, DisplayRegion, DmaTransfer, FrameBuf,
StandardSwapChain, TransferError,
};FrameBuf and DMACapableFrameBufferBackend are re-exported there on purpose:
they appear in the backend trait signatures, so implementing a backend does not
require embedded-graphics-framebuf in your own Cargo.toml.
Everything else — telemetry, config policies, scene_format, physics,
or an individual stage module like pipeline::shade::shader — stays explicitly
named, so it is always obvious which subsystem you reached into.
The engine needs a pixel sink. The only trait involved is
embedded_graphics_core::draw_target::DrawTarget; the whole adapter is ~30
lines:
struct Framebuffer<'a> {
pixels: &'a mut [Rgb565],
width: usize,
height: usize,
}
impl OriginDimensions for Framebuffer<'_> {
fn size(&self) -> Size {
Size::new(self.width as u32, self.height as u32)
}
}
impl DrawTarget for Framebuffer<'_> {
type Color = Rgb565;
type Error = core::convert::Infallible;
fn draw_iter<I>(&mut self, pixels: I) -> Result<(), Self::Error>
where
I: IntoIterator<Item = Pixel<Self::Color>>,
{
for Pixel(point, color) in pixels {
if point.x < 0 || point.y < 0 {
continue; // the rasterizer can emit off-screen pixels
}
let (x, y) = (point.x as usize, point.y as usize);
if x < self.width && y < self.height {
self.pixels[y * self.width + x] = color;
}
}
Ok(())
}
fn clear(&mut self, color: Self::Color) -> Result<(), Self::Error> {
self.pixels.fill(color); // override the default per-pixel path
Ok(())
}
}Full version: integration_minimal.rs.
Depth buffer. ZDepth is u32, or u16 with the depth-u16 feature
(halves the memory). Fill it with Z_MAX_VALUE each frame:
let mut zbuffer = vec![Z_MAX_VALUE; WIDTH * HEIGHT];FrameCtx validates the length when you execute, so a mismatch is a clean
RenderError::OutOfBudget(BudgetKind::ZBufferLength { .. }) rather than a
silent wrap.
Command buffer. CommandBuffer<MAX> holds up to MAX primitives. Size it
above the largest frame you expect, e.g. MAX ≈ max_draw_primitives from your
profile:
let mut commands = CommandBuffer::<8192>::new();Overflow is reported, not fatal: record returns
RenderError::OutOfBudget(BudgetKind::DrawPrimitives { attempted, max }), and
record_with_fallback / record_with_degradation can shed work instead.
Memory math per frame — a 320x240 configuration with depth-u16:
| Buffer | Bytes |
|---|---|
Framebuffer (Rgb565) |
153,600 |
Z-buffer (u16) |
153,600 |
| Command buffer | MAX * size_of::<RenderCommand>() |
| Double buffer | +153,600 |
See docs/feature-size.md for measured .text budgets.
Two calls, deliberately split:
// Record: CPU work — transforms, culling, primitive assembly.
engine.record(core::iter::once(&mesh), &mut commands, None)?;
// Execute: rasterize the recorded commands into your target.
let mut frame = FrameCtx { zbuffer: &mut zbuffer, width: WIDTH, height: HEIGHT };
engine.execute::<_, 8192>(&mut fb, &mut frame, &commands, None)?;Why the split matters for integration:
recordtakes&selfand never touches the framebuffer, so it is safe to run while DMA is still pushing the previous frame — the CPU is not idle.executewrites only to the target you pass. It does not allocate and does not own your buffers.- You must clear both buffers yourself each frame; the engine does not clear unless you record a clear command.
record_with_degradation takes &mut self because applying a degradation step
changes engine state (quality tier).
Full version: integration_app_loop.rs.
You already own the pixel slice, so presentation is whatever your platform does:
memcpy into panel RAM, stream over SPI, or hand the slice to a DMA channel.
For double buffering with an ownership-safe handoff, use a swap chain:
use embedded_3dgfx::pipeline::output::swapchain::StandardSwapChain;
let mut swap = StandardSwapChain::<320, 240, _>::from_static_slices(
front_data, // &'static mut [Rgb565]
back_data, // &'static mut [Rgb565]
false, // true if your panel wants big-endian pixels
MyPanelBackend::new(),
);
loop {
// The back buffer is always available; DMA only ever touches the front.
let back = swap.get_back_buffer();
back.clear(Rgb565::BLACK)?;
// …record + execute into `back`…
swap.present()?; // blocking: waits for the previous transfer
// or: swap.try_present()? -> Err(DisplayError::Busy) if still transferring
}DisplayError::Busy is normal back-pressure, not a failure: wait with
swap.wait_for_vsync() (or drop the frame) and retry.
Presentation goes through two traits in the output stage:
DisplayBackend<W, H, FB>— starts a transfer of a framebuffer.DmaTransfer— the in-flight token. It owns the framebuffer until the hardware finishes, so the borrow checker prevents writing to a buffer the panel is still reading. ImplementDropto cancel the DMA channel, so a dropped token can never leave the controller pointing at freed memory.
impl<FB: DMACapableFrameBufferBackend<Color = Rgb565>> DmaTransfer for MyTransfer<FB> {
type Buffer = FrameBuf<Rgb565, FB>;
fn is_done(&self) -> bool { /* read the DMA status flag */ true }
fn wait(self) -> Self::Buffer { /* WFI until the ISR fires, then return it */ }
}Full version (including back-pressure handling):
integration_custom_backend.rs.
CompletionSlot is runtime-agnostic. Signal it from the DMA interrupt, then use
WaitTransfer as the token — it polls the same slot synchronously, and doubles
as a future under an async executor.
use embedded_3dgfx::pipeline::output::completion::{CompletionSlot, WaitTransfer};
static DMA_DONE: CompletionSlot = CompletionSlot::new();
#[interrupt]
fn DMA_IRQ() {
DMA_DONE.signal(); // safe from interrupt context
}
// in your backend:
fn start_dma_transfer(&mut self, fb: FrameBuf<Rgb565, FB>) -> Result<Self::Transfer, _> {
// …program the DMA channel with `fb`'s address and length…
Ok(WaitTransfer::new(fb, &DMA_DONE)) // resets the slot for you
}If your executor can await, both the transfer and the present are awaitable:
swap.present_async().await?; // requires AsyncDmaTransfer on the token
swap.wait_for_vsync_async().await;The triple-buffering feature adds TripleSwapChain, which rotates render /
ready / display buffers so the CPU never waits for scan-out. Use it when your
frame times are bursty.
apply_default_caps adopts a per-MCU budget profile (defaults to
PROFILE_M33_BALANCED, which caps a frame at 320×240):
use embedded_3dgfx::config::{apply_default_caps, PROFILE_M55_PERF};
apply_default_caps(&mut engine); // default profile
engine.set_caps(PROFILE_M55_PERF); // or pick one explicitly
engine.clear_caps(); // or lift the caps entirelyRuntime override for desktop builds: EMBEDDED_3DGFX_CAPS=off (or
=m0|m3|m4|m33|m55). Compile-time: the desktop-unbounded feature.
Telemetry is opt-in and free when unused — pass None:
let mut record_tel = RecordTelemetry::default();
let mut execute_tel = ExecuteTelemetry::default();
engine.record(&meshes, &mut commands, Some(&mut record_tel))?;
engine.execute::<_, 8192>(&mut fb, &mut frame, &commands, Some(&mut execute_tel))?;RecordTelemetry gives meshes_visible, unique_textures, draw_commands;
ExecuteTelemetry gives commands_total and the clear counts. Feed them to a
HUD or a host-side logger.
Graceful degradation — instead of failing a heavy frame, shed work:
static DEGRADATION: [DegradationStep; 2] = [
DegradationStep::MeshDecimationStride(2),
DegradationStep::DowngradeQuality,
];
let outcome = engine.record_with_degradation(
&meshes,
&mut commands,
DegradationPolicy { steps: &DEGRADATION },
Some(&mut record_tel),
)?;
if outcome.used_degradation {
// surface it: the frame is cheaper but visibly different
}Both steps are no-ops on a frame that fits, so the policy can stay enabled permanently.
Textures live in a fixed-capacity TextureManager<N> holding 'static pixel
data, and are looked up by the id on Geometry::texture_id:
use embedded_3dgfx::pipeline::rasterize::texture::{Texture, TextureManager};
let mut textures = TextureManager::<8>::new();
let id = textures.add_texture(Texture::new(include_bytes!(...), 32, 32)).unwrap();
let mut frame = FrameCtx { zbuffer: &mut zbuffer, width: W, height: H };
engine.execute_with_textures::<_, 8192, 8>(
&mut fb, &mut frame, &commands, &textures, None,
)?;Geometry borrows its slices, so keep vertex/face/texture arrays alive for at
least as long as the K3dMesh — &'static data straight from flash is ideal
(see docs/asset-pipeline.md for the offline generator).
HUD / 2D overlay. Anything that implements DrawTarget can draw on top of
the 3D frame — including embedded-graphics primitives and text:
engine.execute::<_, 8192>(&mut display, &mut frame, &commands, None)?;
let style = MonoTextStyle::new(&FONT_6X10, Rgb565::CSS_WHITE);
Text::new(&format!("{fps:.1} fps"), Point::new(4, 10), style).draw(&mut display)?;Draw it after execute, and remember the z-buffer is independent of your 2D
overlay.
Custom passes that match the engine's effects. engine.raster_state(w, h)
returns the engine's own per-pass configuration — fog, dither, tint, palette,
stipple, depth bias, depth interpolation. Feed it to a raster entry point and
your hand-rolled geometry is pixel-identical to the engine's:
use embedded_3dgfx::pipeline::rasterize::draw::draw_zbuffered_with_state;
let state = engine.raster_state(WIDTH, HEIGHT);
draw_zbuffered_with_state(primitive, &mut display, &mut zbuffer, &state);Custom materials. FragmentShader is the seam for procedural shading. The
built-in decorators wrap any shader, so fog/dither apply to your material too:
let material = MyWaveShader { base_color, time };
let fogged = FogShader { inner: material, fog: &fog_config };
let shaded = DitherShader{ inner: fogged, dither: &dither_config };
let color = shaded.shade(x, y, z, interpolants);Note this is an evaluation API: you compose the stack and sample it per
fragment. The engine's built-in rasterization is driven by DrawPrimitive, so a
custom material is used either to compute colours you feed into primitives, or
in your own raster pass on top of RasterState.
| Target | Suggestion |
|---|---|
| Desktop / simulator | std, desktop-unbounded |
| Cortex-M0/M0+ | row_width_96, depth-u16, no lighting/textured |
| Cortex-M4F | row_width_160, depth-u16, lighting |
| Cortex-M33/M55 | row_width_240, depth-u16, lighting, textured |
| Doom-style raycaster | add raycast, hud |
| Physics / animation | add physics, scene |
row_width_* sizes the internal scanline buffer; it must cover the frame width.
If several are enabled the widest wins deterministically.
- Clear both buffers before
execute. The engine does not clear for you. FrameCtxdepth length must equalwidth * height— otherwiseRenderError::OutOfBudget(BudgetKind::ZBufferLength).- Default caps are 320×240. A larger frame fails with
OutOfBudget(FramebufferDimensions { .. })until you raise the caps (desktop-unbounded,EMBEDDED_3DGFX_CAPS=off, orset_caps). - Command buffer capacity is primitives, not pixels. Undersizing it
surfaces as
BudgetKind::DrawPrimitives. DisplayError::Busyis routine. Handle it as back-pressure.- Public enums are
#[non_exhaustive]. Match with a_arm — includingDisplayError,RenderModeand the error taxonomy. record_with_degradationneeds&mut engine, unlike plainrecord.- Geometry borrows its data.
K3dMeshcannot outlive the arrays behind itsGeometry. Build one withGeometry::new(&vertices, &faces)and add the optional slots with thewith_*builders — or useGeometry { vertices, faces, ..Default::default() }if you already hold the data as named struct fields. - With the simulator, call
window.update()once beforewindow.events()— the event iterator panics otherwise.
| Example | Seam it demonstrates | Run |
|---|---|---|
integration_minimal |
your own DrawTarget, one frame, no SDL |
cargo run --example integration_minimal --features std |
integration_app_loop |
fixed timestep, input, telemetry, degradation, RasterState overlay |
cargo run --example integration_app_loop --features std |
integration_custom_backend |
DisplayBackend / DmaTransfer, swap chain, back-pressure |
cargo run --example integration_custom_backend --features std |
All three accept E3DGFX_EXAMPLE_FRAMES=<n> to exit after n frames, which
makes them usable as headless smoke tests:
# No display server needed.
SDL_VIDEODRIVER=dummy E3DGFX_EXAMPLE_FRAMES=60 \
cargo run --example integration_app_loop --features std| Document | Topic |
|---|---|
MIGRATION.md |
Upgrading from the pre-pipeline module layout |
docs/backend-integration.md |
Board bring-up, memory sizing, hardware profiling |
docs/caps-and-telemetry.md |
Caps, telemetry, CI budgets |
docs/feature-size.md |
Slim vs full flash (.text) budgets |
docs/asset-pipeline.md |
Offline assets, scene streaming |