View on GitHub

FOnline Engine

Flexible cross-platform isometric game engine

Frontend and Rendering

Engine-owned documentation. This page describes the reusable application, input, audio, window, and rendering abstractions under Source/Frontend/ plus the client render-target bridge in Source/Client/.

Purpose

The frontend layer is the boundary between the platform and the engine runtime. It owns windows, frame boundaries, input queues, touch/gamepad translation, audio device access, renderer selection, and low-level render backend objects. The client runtime consumes this layer through stable interfaces instead of calling SDL, OpenGL, Direct3D, or Web APIs directly.

Read this page together with:

Source paths inspected

Layer map

The frontend/rendering split has three layers:

  1. Application layer (Application, AppWindow, AppInput, AppAudio, AppRender) owns platform services and frame boundaries.
  2. Renderer layer (Renderer and its backends) owns GPU/null rendering resources: textures, draw buffers, effects, matrices, scissor state, presentation, and resize handling.
  3. Client drawing layer (SpriteManager, RenderTargetManager, EffectManager, MapView) builds engine/game drawing operations on top of the renderer abstraction.

This keeps most client code renderer-agnostic. The client asks for sprites, effects, draw buffers, render targets, and input events; the selected backend decides how those are implemented.

Matrix convention

Engine render math uses one matrix convention:

Use RowMajor/ColumnMajor naming only for explicit boundary conversion with external data formats. Internal renderer, model, particle, and geometry code should name matrices by role (ProjMatrix, ViewMatrix, ViewProjMatrix, WorldMatrix) rather than by storage order.

Application initialization

InitApp() and LoadAppSettings() in Source/Frontend/ApplicationInit.cpp prepare global settings and application services before the client/server/tool app creates its engine object.

Notable responsibilities:

Application initialization is intentionally shared by more than the graphical client. Server, mapper, editor, testing, and package flows may use different flags or window modes, but they should still go through the shared frontend setup where applicable.

Application services

Source/Frontend/Application.h defines the public frontend surface.

Application

Application owns process-level frontend state:

AppWindow / IAppWindow

Window responsibilities include:

AppInput / IAppInput

Input responsibilities include:

Mouse button input preserves the concrete platform button id when mapping to script-facing MouseButton values (Left, Right, Middle, Ext0/Ext1, …); unknown native buttons are ignored rather than falling back to a primary click.

The client turns these lower-level events into script events in ClientEngine::ProcessInputEvent().

SDL mouse-motion events are the primary source for InputEvent::MouseMoveEvent. On backends where SDL exposes global mouse coordinates (Windows, macOS, X11, and the whitelisted OS/2 drivers), Application::BeginFrame() also polls global mouse state while the app remains focused. If no SDL motion event arrived in that frame and the global position changed, the frontend synthesizes a mouse-move event from the global position. This keeps the game cursor and edge-scroll state updating when the OS pointer has moved outside the client window instead of freezing at the last in-window event. The same host-to-active-window translation path is used for this synthetic event, so embedded virtual clients still receive local logical coordinates through their display rect and aspect-fit mapping.

AppAudio / IAppAudio

Audio responsibilities include:

Headless and stub modes

Two non-normal modes are important for tools, tests, CI, and platform staging:

The stub layer is not a full renderer. It exists so tests and non-graphical flows can exercise engine logic without assuming that a real GPU/window/audio device is available. When a test depends on visible rendering, it should say so explicitly instead of relying on stub behavior.

Rendering abstraction

Source/Frontend/Rendering.h defines the renderer-facing types:

Source/Frontend/Rendering.cpp owns backend-independent helper behavior, including draw-buffer allocation checks and effect configuration parsing. It reads effect sections such as Effect and EffectInfo, pass counts, blend settings, and script-visible buffers before backend-specific code consumes shader files.

EffectUsage::QuadSprite is a historical effect-slot name, not a four-vertex topology restriction. The sprite draw buffer is an indexed triangle list, and source-backed AtlasSprite frames may emit their baked silhouette vertices and indices instead of the implicit 4-vertex/6-index rectangle. All renderer backends consume the same buffer; no backend-specific polygon path exists.

Local mesh coordinates are relative to the exact bounding box of the selected geometry. The baked frame carries the original logical bitmap size and the cropped-frame origin within it, while its individual sprite offset preserves the original logical root. Screen position and atlas UV are affine mappings of the same cropped coordinate, so scaling, rotation, map projection, standing-sprite depth, and egg flags continue to operate over every emitted vertex without retaining unused texture rows or columns. Map lighting also preserves the old full-bitmap quad plane: DrawSprites still provides the left/right endpoint colours, and a mesh vertex at cropped local X receives lerp(left, right, clamp((localX + sourceOffsetX) / sourceWidth, 0, 1)). The factor is the vertex’s normalized horizontal position within the original bitmap — 0 at its left edge, 0.5 at its centre, 1 at its right edge — and is not measured from the cropped bounds or the opaque contour. Because Vertex2D::Color is RGBA8, intermediate mesh vertices may differ from ideal quad interpolation by at most one channel unit due to rounding.

Only ordinary full-image sprite draws submit baked polygon meshes. Every atlas allocation still has a valid rectangular GetAtlasRect(); polygon baking can make that physical frame smaller than, and offset within, the original logical image. Region crops, tiled patterns, padded custom-effect/outline draws, and mapper previews map that physical rectangle into logical coordinates through SourceOffset. Region UVs remain normalized to the original logical image, and transparent cropped margins are clipped out of the destination rectangle. A polygon crop therefore cannot shift or stretch a GUI 9-slice, repeated pattern, preview, or source-region composition. Effects that need to create pixels outside the source silhouette must use such a padded/quad path rather than an ordinary full-sprite draw.

Runtime model sprites may still use a cropped quad, but their logical layout is automatic. .fo3d no longer accepts DrawSize or ViewSize, and the corresponding default render settings no longer provide fallback dimensions. ModelAnimationInfo.foinfo bounds schema version 2 supplies an aggregate root-space model AABB, a dedicated idle-priority view AABB, and individual animation AABBs. In FO_ENABLE_3D builds, the common EngineMetadata loader parses and validates the complete companion once; rendering requests immutable model records from that registry rather than maintaining a second client-side config parser or bounds cache.

The client projects enabled animation bounds through the active base transform and derives their extrema for every continuous facing angle. Body plus projected shadow determines both the animation-wide DrawRect and the active logical scratch-frame dimensions. The separate view bound prefers Unarmed + Idle, then any Idle, then a deterministic animation/static fallback; projecting it over all directions yields the stable ViewRect. Each frame dimension is rounded up to a power of two, and the ground root remains at (DrawWidth / 2, 3 * DrawHeight / 4). The view rectangle deliberately excludes the shadow and remains independent from the changing atlas crop, so names, coarse picking, transparent eggs, and flying-text placement do not jitter when the model turns or changes animation.

The automatic logical frame owns the reusable 2x scratch render target. After the pose is evaluated, the client combines its per-animation prediction with an exact weighted envelope of the referenced vertices in the generated, currently active skinned meshes and their projected shadow. If that exact envelope needs a larger logical frame, the client expands the frame and rerenders before copying; a bounded retry loop rejects a layout that does not converge. Only the selected region is allocated and copied into the atlas. The crop origin is reflected in the sprite offset, preserving the automatic frame’s root, hit-test coordinates, and map positioning. The active layer/child-model tree extends the idle-priority base view and aggregate lighting bounds. Animation switches may refresh DrawRect and the logical scratch frame, but ViewRect keeps using the accumulated model-and-layers view envelope rather than temporarily replacing it with the root model’s smaller idle view; the name anchor therefore remains stable throughout turn animations. Left/right map-light colours are sampled at root-relative crop endpoints on that configuration envelope, so animation-driven scratch-size changes do not alter the light mix and wide gear does not clamp to a base-model endpoint colour.

Within one active animation/combined-mesh envelope, later pose changes only expand the slot. The envelope identity changes when enabled body/movement tracks settle after a transition, generated mesh composition changes, or shadow coverage changes; that permits one shrink to the new stable envelope instead of accumulating every animation played during the sprite’s lifetime. Direction is deliberately not part of the identity, so ordinary turns retain a stable high-water slot. New placements are reserved and copied before the sprite publishes its frame/crop/allocation; a failed copy leaves the previous allocation live and schedules a retry. Active model-attached particles use SPARK’s live render AABB after the first update, fall back to the advertised canvas before it exists, and select the entire current frame. Scratch-frame changes rebase already emitted atlas-space particles before rerendering; non-default model effects also disable the tight crop. This protects ordinary skinned output but is not a shader-displacement bound: an effect that moves vertices outside the normal geometry needs a separate conservative contract. Render.ModelDirectDraw retains atlas-side preview and hit-test data while visible geometry continues to draw directly in the scene.

Game.DumpAtlases() and the mapper’s Dump atlases command annotate the read-back TGA copy with the live allocation geometry; the runtime atlas texture is not modified. Magenta lines show triangle edges, cyan pixels show mesh vertices, yellow rectangles identify implicit quad geometry, and a red X marks an explicitly empty baked frame. AtlasSprite owns its mesh metadata; the live atlas allocation keeps a nullable non-owning observer into that data and clears it when the space is released, so a dump cannot display stale geometry after an atlas slot is reused.

AtlasSprite keeps the authored logical size and offset separate from the cropped atlas allocation. Mesh vertices are positioned through their SourceOffset in logical-canvas coordinates while UVs remain local to the cropped allocation. GetSize(), GetOffset(), scaling, and hit testing therefore retain the source-image contract even when baking removes transparent border pixels. Atlas cropping reduces texture memory without becoming visible to GUI layout, sprite anchoring, or input routing.

Runtime atlas allocation remains per image, but TextureAtlasLayout uses dynamic MaxRects placement instead of an order-sensitive guillotine tree. It retains overlapping maximal free rectangles and chooses the best short-side fit, then long-side fit and wasted area, without rotating images. The manager evaluates that fit across every existing atlas of the requested type before it creates another page; equal page-level scores keep the older atlas. The packed rectangle already includes the one-pixel texture border, so the algorithm does not change filtering padding, sprite pixels, or UV calculation.

Font sheets, model material textures, particle texture maps, and Spine attachment textures are rectangular image consumers, not drawable polygon sprites. Their authored glyph or normalized UV coordinates address the complete source bitmap and their consumers receive only an atlas rectangle, without a SourceOffset. They therefore load through SpriteManager::LoadSpriteAsQuad, which uses the baked mesh metadata only to restore the original logical canvas before atlas upload. Loading them as ordinary AtlasSprite instances would expose mesh padding/cropping dimensions to the authored coordinates and shift their UVs. Runtime-generated model and particle sprites already occupy ordinary rectangular atlas allocations and do not need this reconstruction.

Each live sprite owns an engine unique_del_* handle to an encapsulated, stable-address TextureAtlasLayout::Allocation. Releasing it clears the mesh observer in constant time and marks the derived free-rectangle list dirty. The next allocation rebuilds that list once from all still-live rectangles, in a deterministic order, so batches of unloads are coalesced and no surviving sprite, pixel region, or UV ever moves. This runtime-only layout change does not add settings or alter sprite-resource serialization.

Render.DrawWireframe enables a backend-independent runtime geometry overlay. SpriteManager copies the actual submitted triangle edges after positioning, scaling, rotation, map projection, and standing-sprite depth adjustments, then draws them as an opaque magenta primitive line list over the normal sprite pass. This also exposes the two triangles of ordinary quad sprites, so it is independent of SpriteMesh.Enabled. The toggle is disabled by default and does not modify the sprite draw buffer, texture atlas, or baked resource.

Render backends

Null renderer

Source/Frontend/Rendering-Null.cpp implements Null_Renderer, Null_Texture, Null_DrawBuffer, and Null_Effect.

Use it for tests, headless flows, and validation that should not require a GPU. It still validates dimensions, buffer counts, render-target state, and texture region access, so it is useful for catching many API misuse errors.

OpenGL renderer

Source/Frontend/Rendering-OpenGL.cpp implements the OpenGL/WebGL path.

Important behaviors:

OpenGL is the path to inspect for WebAssembly/WebGL behavior. Pair renderer changes with WebDebugging.md validation.

Direct3D renderer

Source/Frontend/Rendering-Direct3D.cpp implements the Direct3D 11 path.

Important behaviors:

Direct3D changes are Windows-specific and should be validated through a Windows embedding-project build/debug flow.

Vulkan renderer

Source/Frontend/Rendering-Vulkan.cpp implements the Vulkan path. It is built by default (opt out with FO_DISABLE_VULKAN; also skipped for headless-only and web builds) and needs no external Vulkan SDK — there is no find_package(Vulkan). The build compiles against the Vulkan headers already vendored with SDL3 (ThirdParty/SDL/src/video/khronos, wired as a SYSTEM include when FO_HAVE_VULKAN), and the loader is resolved dynamically at runtime. It is selected at runtime by Render.ForceVulkan (or as an automatic fallback when no other backend is configured).

The loader is not linked at build time (vulkan-1.lib is never referenced). Instead Rendering-Vulkan.cpp compiles with VK_NO_PROTOTYPES and resolves every entry point dynamically through SDL — SDL_Vulkan_LoadLibrary + SDL_Vulkan_GetVkGetInstanceProcAddr bootstrap vkGetInstanceProcAddr, then a small X-macro table (mirroring the OpenGL backend’s SDL_GL_GetProcAddress table) loads global functions with a null instance and the rest from the created instance. Consequently a client built with Vulkan support carries no load-time vulkan-1.dll import and still launches on a machine without the Vulkan runtime; the loader is pulled in only when the Vulkan backend is actually selected (a missing runtime then throws from SDL_Vulkan_LoadLibrary, not at process start).

Design and important behaviors:

Vulkan changes should be validated on a platform with the Vulkan SDK by running a visible client with Render.ForceVulkan=True Render.RenderDebug=True and confirming the log has no [VkLayer] errors.

SDL_GPU renderer

Source/Frontend/Rendering-SDLGpu.cpp implements a second, opt-in backend on top of SDL3’s SDL_GPU API, which reaches Vulkan / Metal / D3D12 through one implementation (the vendored SDL3 already ships all three drivers). It is built by default (FO_HAVE_SDL_GPU), skipped only for headless-only and web builds, and can be force-disabled with FO_DISABLE_SDL_GPU; unlike Vulkan it needs no external SDK because the SDL3 GPU drivers are vendored. It is selected at runtime by Render.ForceSDLGpu (auto-selection is unchanged — it never becomes the automatic default). The optional Render.SDLGpuDriver pins a specific SDL_GPU driver (vulkan / metal / direct3d12); Render.RenderDebug maps to the SDL_GPU debug mode (Vulkan validation layers on the Vulkan driver).

Design and important behaviors:

Validate SDL_GPU changes with a client scene launch under Render.ForceSDLGpu=True Render.RenderDebug=True (Vulkan validation on the Vulkan driver), confirming a clean map/GUI render with no validation errors, side by side against the default backend.

Render targets and client bridge

Source/Client/RenderTarget.h and Source/Client/RenderTarget.cpp are the client-side bridge from high-level drawing code to backend textures.

RenderTargetManager responsibilities:

MapView, SpriteManager, ModelSpriteFactory, and ParticleSpriteFactory all rely on render targets for map layers, light buffers, model/particle atlas rendering, hit testing, and offscreen composition.

Model-attached SPARK particle systems keep already spawned particles in their simulation space while the emitter follows the model attachment point. A non-identity root transform in the particle resource selects the position-plus-facing path instead of inheriting the full bone matrix; this keeps lingering particles world-stable during model movement while new particles spawn at the current attachment point. The model movement offset is subtracted in particle model space before camera rotation and projection so the setup-time positive offset and draw-time negative offset cancel for newly emitted particles.

Screen size, resolution, and letterboxing

Two distinct sizes drive client rendering:

The game always renders into _rtMain at the logical size; the final blit (Renderer::SetRenderTarget(nullptr) in the backends) then stretches/upscales _rtMain with aspect ratio preserved into the backbuffer (centered, with bars only when the aspects differ). This is deliberate: fullscreen must scale the chosen logical resolution up to the monitor without non-proportional distortion. When the two sizes are equal the blit is 1:1 with no bars. Accordingly _rtMain is sized to GetScreenSize() and is resized on the screen-size-changed event. Dispatchers are semantic: OnScreenSizeChanged fires only when the logical screen size changes, while OnWindowSizeChanged fires when the physical/host window changes.

Script offscreen surfaces (Game.ActivateOffscreenSurface / Game.PresentOffscreenSurface) also operate in the logical screen coordinate space, because scripts draw them while _rtMain is active. Pooled offscreen render targets must therefore be created at SpriteManager::GetScreenSize() and resized when the logical resolution changes before they are reused; otherwise effects such as monitor-noise GUI composition can clip content that moves outside the old resolution. SpriteManager applies its active scissor stack while flushing to these surfaces as well as to _rtMain, so a cropped GUI subtree keeps the same viewport boundary when it is wrapped in an offscreen effect.

Windowed

Window pixel size and logical screen size are kept equal. Resizing the OS window raises SDL_EVENT_WINDOW_PIXEL_SIZE_CHANGED; while the main window is not fullscreen, that event writes Settings.ScreenWidth/Height from the new pixel size, fires OnWindowSizeChanged, and fires OnScreenSizeChanged only when those settings actually changed. Game.SetResolution(w, h) first updates the logical size through SetScreenSize, then resizes the OS window only when the client is neither fullscreen nor virtual; the following OS-window resize is treated as a window-size event only if it reports the same logical size, avoiding a second GUI/map screen-size refresh for the same resolution change.

Fullscreen (borderless desktop)

The window uses SDL_SetWindowFullscreenMode(window, nullptr), so the framebuffer is always the monitor size and cannot be resized to a sub-monitor resolution. A “resolution” in fullscreen is the logical render size: Game.SetResolution changes the logical size (SetScreenSize), and the backbuffer blit stretches/upscales that logical render to the monitor with aspect ratio preserved. Fullscreen startup, fullscreen toggles, and fullscreen SDL_EVENT_WINDOW_PIXEL_SIZE_CHANGED events update the renderer/backbuffer only; they must not overwrite Settings.ScreenWidth/Height or fire OnScreenSizeChanged, otherwise the selected logical resolution collapses to the monitor size and there is nothing left to stretch. AppWindow::ToggleFullscreen() marks the transition before calling SDL because SDL can queue the pixel-size event while the OS/window flags still appear to be in the previous mode. This is not a non-proportional stretch; bars are expected only when the selected logical aspect differs from the monitor aspect.

SDL documents that SDL_SetWindowSize has no effect while a window is fullscreen or maximized, so the engine must not rely on that call changing the live fullscreen framebuffer. For native non-virtual clients, Game.SetResolution still records the requested size as the pending windowed size while fullscreen. When the client leaves fullscreen, SpriteManager::ToggleFullscreen() applies that pending size to the restored window and then re-centers the window using the accumulated resolution delta. This preserves both rules: fullscreen presents as aspect-preserving stretch to the monitor, and returning to windowed mode uses the last selected resolution as the OS window size.

Embedded clients in the multi-client host (virtual windows)

ServerApp can host several embedded clients (the Single/Tile/Cascade layouts, Spawn Client). Each embedded client is its own engine instance with its own GlobalSettings and a virtual AppWindow (IsVirtual()). A virtual window:

Because each embedded engine owns its settings, a resolution change must update the owning engine’s settings, not the host’s. Virtual AppWindow::SetScreenSize/GetScreenSize store the logical size in _virtualScreenSize, while SpriteManager::SetScreenSize mirrors the new size into the embedded engine’s own Settings.ScreenWidth/Height before the screen-size-changed handlers run. SetResolution skips SetWindowSize for virtual windows, and SetScreenSize does not mutate _virtualSize, so changing a client resolution no longer resizes the virtual render texture or the host layout. A standalone client has a single engine where the engine’s settings and App->Settings are the same instance, so the real window handles it directly.

GUI screens re-center on a resolution change through the client’s OnScreenSizeChanged handler → Gui::Callback_OnResolutionChanged(), which re-runs each screen’s layout against the current Settings.View.ScreenWidth/Height (a screen with Anchor: None is centered against the parent/screen size). This is why both _rtMain/GetScreenSize() and the engine’s own settings must reflect the new logical size: the render target controls what is drawn, the settings control where the GUI lays it out.

Local-map viewports recenter instantly on the chosen critter when their screen size actually changes. This keeps the player anchored after resolution changes in standalone clients, fullscreen logical-resolution changes, and embedded virtual clients. MapView must derive that size from the logical client screen size, not from the physical OS window/backbuffer size; fullscreen scaling is handled by the final render-target blit.

Effects and shader data

RenderEffect owns standard buffers used by render paths:

EffectManager in Source/Client/EffectManager.h loads minimal/default effects, resolves script-selected effects, writes script-value buffers, and performs per-frame updates. Scripts can write one ScriptValueBuf float with Game.SetEffectScriptValue(...), or write a contiguous range with Game.SetEffectScriptValues(effectType, effectSubtype, valueStartIndex, values, valuesOffset = 0, valuesCount = -1) to avoid repeated native calls when updating shader parameter blocks. Both APIs validate the selected effect, require the shader to declare ScriptValueBuf, and enforce the configured EFFECT_SCRIPT_VALUES range.

Shader time is session-relative and wrapped. TimeBuf (FrameTime.x / GameTime.x, seconds) is rebased to the first rendered frame and wrapped at 8192 s by EffectManager::PerFrameEffectUpdate — it is a periodic animation-phase input, not an absolute clock. The raw steady-clock time is seconds since OS boot (days-scale on long-running machines), and even session-relative time reaches 10^5–10^6 s on clients embedded into long-running servers; at such magnitudes fp32 fract()/hash/sin math degrades into visible stepping (high-frequency phases like sin(t * 76) break within a day) and the clock granularity eventually exceeds the frame delta. The fp32-exact wrap keeps granularity under 1 ms for any session length at the cost of a once-per-~2.3h phase pop, which consumers must keep on noisy/ambient math. Effects that feed the time into hash lattices should still wrap locally (mod(p, period) noise lattices in the fog effects); script-side accumulated effect clocks (e.g. weather anim clocks passed through ScriptValueBuf) need the same treatment — an fp32 accumulator that only grows will first quantize and then freeze once its ulp exceeds the per-tick increment.

When adding an effect feature, document whether the change belongs in:

Minimal-profile base effects

The engine ships a fixed set of base effects under Resources/Core/Effects/ (loaded as the default for each draw slot by the LOAD_DEFAULT_EFFECT table in Source/Client/EffectManager.cpp) plus a few bootstrap effects under Resources/Embedded/Effects/ (compiled into the binary so the renderer can draw before external resource packs are mounted). Each .fofx opens with a top-of-file # comment header stating what the effect does, which slot uses it, and how it works.

These base shaders are deliberately written for the lowest Direct3D feature level (down to feature level 9_x): no gl_FragCoord / position-semantic reads, no screen-space derivatives (dFdx/dFdy/fwidth), and no dynamic array/vector indexing — only constructs that compile and run on the weakest supported hardware. The cross-compiler still emits HLSL Shader Model 4.0, GLSL 330, GLSL ES 300 and Metal for every effect (Source/Tools/EffectBaker.cpp), but the source must avoid features that fail on the lowest runtime profile. Keep an engine base effect minimal; that is what its header’s Profile: minimal line records.

Default slot → effect mapping (Source/Client/EffectManager.cpp): Font/Iface/Generic/Critter/Rain2D_Default; Roof/Tile/Flat2D_NoDepth; PrimitivePrimitive_Default; LightPrimitive_Light; FogPrimitive_Fog; FlushPrimitive/FlushMap/FlushLight/FlushFog/FlushRenderTarget → the matching Flush_*; SkinnedModel3D_Skinned; ImGui → the ImGuiDefaultEffect setting (ImGui_Default). 2D_WithoutEgg, 3D_NormalMapping, Flush_Map_BlackWhite, Font_Default, Interface_Default and the Particles_* set are available effects selected per-draw / per-mesh / by the particle system rather than fixed slot defaults.

FlushMap is the boundary between the intermediate map render target and the completed viewport layer. Its RGB is already alpha-composited, so a map flush effect must neither multiply RGB by the render-target alpha nor propagate that intermediate coverage into the completed frame. Both Flush_Map and the optional Flush_Map_BlackWhite therefore write opaque output alpha (1.0); embedding-project FlushMap overrides must preserve the same contract. Generic FlushRenderTarget remains an RGBA-preserving blit because model, particle, GUI, and other offscreen surfaces still need their authored alpha.

An embedding project that targets richer hardware keeps its own advanced-profile copies in a resource pack that bakes after Core/Embedded under the same resource name, so the project copy shadows the engine base at runtime while the engine keeps the minimal fallback. The richer copy is free to use gl_FragCoord, derivatives, per-fragment lighting, and similar; the engine base is not.

Per-effect depth state and the shared map depth buffer

Effects carry per-pass depth state parsed from the .fofx [Effect] block:

The map render target (MapView::_rtMap) is created with_depth, giving the world one shared depth buffer. EffectUsage::QuadSprite and EffectUsage::Model effects participate in it (depth state is a hardware no-op on targets without a depth attachment — UI, light, flush-to-screen):

Direct-to-scene sprites

A Sprite may override IsDirectDraw() to render its own geometry straight into the current scene render target (with the shared depth buffer) instead of being batched as an atlas quad. Because such a sprite uses its own shader (not the sprite batch’s), drawing it at its interleaved draw-order position would split the sprite batch around every one. Instead SpriteManager::DrawSprites collects direct-draw sprites during the batch loop and replays them in a single Sprite::DrawInScene(scene_pos, depth) pass (a const method, like FillData) after the whole sprite batch is flushed — so the batch stays intact. Opaque sprites write depth (DepthFunc = Always, DepthWrite = True) and direct-draw transparents only test it (LessEqual, DepthWrite = False), so scene occlusion comes from the shared depth buffer. Direct-draw anchors use the projected hex + HexOffset + SpriteOffset/TweakOffset + Elevation map position, deliberately excluding viewport-only field.Offset, and keep only a single computed anchor-bias step instead of inheriting their late draw order; otherwise DrawOrderType::Particles would become depth-closer than critters/scenery before the particle geometry itself is even considered.

ParticleSprite supports two render types, chosen per particle system by the SparkQuadRenderer draw in scene .spark attribute (ATTRIBUTE_TYPE_BOOL, default false — alongside draw size):

ParticleSprite::Play() respawns its backend-neutral ParticleSystem before starting updates. The facade delegates through ParticleRuntimeSystem; renderer-facing code contains no SPARK/Effekseer dispatch or unnamed default branch. One-shot SPARK systems can therefore be replayed after Game.PlaySprite(...) or after AnimFree/AnimLoad cache reuse.

Seeded respawn is deterministic per particle-system instance in both bundled runtimes. Effekseer applies the seed to its manager handle. Each SparkParticleRuntimeBackend owns an explicit SPKContext containing its IO registry, default zone, and ambient generator state. Every loaded SPARK graph is bound to that context before attribute import. Each SparkParticleRuntimeSystem retains its own generator state and temporarily binds it to the owning context while cloning, prewarming, or updating, so interleaved effects and separate engine instances cannot perturb a seeded effect’s sequence.

SparkExtension.h exposes only the backend facade, forward declarations, and plain renderer data helpers. The SPARK headers, SparkQuadRenderer, and its render-buffer adapter remain private to SparkExtension.cpp; Mapper and baker inspect renderer properties through the data helpers instead of depending on the concrete renderer type.

ParticleSystem::SetScale() updates the cached neutral runtime setup, reapplies it with a zero-delta transform refresh, and forces an atlas redraw without respawning or resetting elapsed time. The same contract therefore applies to atlas and direct-scene sprites and to every enabled particle runtime.

The same sprite and direct-scene paths also host the core-only Effekseer runtime. Effekseer renderer interfaces are used as evaluated-data callbacks, not graphics backends: FOnline copies callback values, builds its own RenderDrawBuffer, selects its own RenderEffect, and submits through the normal renderer abstraction. This keeps Mapper and game preview on one path and requires no Direct3D/OpenGL/Vulkan/SDL GPU code from Effekseer.

The Sprite and Ring callback collectors fail closed on malformed callback topology. They enforce both the fixed supported-instance hard limit and the exact instance count declared by BeginRendering; subsequent Rendering calls cannot append more instances than that declaration. Ring packets copy the evaluated outer/center/inner shape and color values, reproduce the upstream eight-vertex/twelve-index segment topology and angular fades, and preserve Z-sort order while splitting large geometry at 64,000 vertices for 16-bit index builds.

Source/Tests/Test_EffekseerParticleRuntime.cpp carries self-contained cooked fixtures that exercise the real Effekseer callback-to-FOnline-draw-buffer path without a stock Effekseer graphics backend. The legacy fixture verifies fixed-seed determinism, repeated fixed-step generation, multi-instance callback-to-draw topology, generated quad geometry and index order, and atlas-remapped UV coordinates. A project-authored Effekseer 1.80.5 fixture additionally verifies that cooked None, NormalOrder, and ReverseOrder Sprite Z-sort modes reach the callback and produce the expected quad depth order. A modern SKFE/1810 upstream TestData fixture verifies deterministic Ring topology, radii, UVs and index order, all three Ring Z-sort modes, and chunking across the 64,000-vertex safety budget that prevents 16-bit index overflow.

The initial callback adapter accepts one Default-material color texture. Ring nodes may omit it and then draw against a renderer-owned white pixel so their vertex colors still match Effekseer. For an authored texture, its requested Linear/Nearest mode must match the loaded FOnline atlas texture, and the sampler must request Clamp; Repeat and Mirror are rejected regardless of the UV values. Every textured callback UV rectangle must also stay inside [0,1] instead of silently sampling neighboring atlas content. Per-effect sub-rectangle wrapping is a separate renderer capability. Modern editor exports may retain a non-zero distortion-intensity value while the Default material has distortion disabled; that dormant value is ignored, while an active distortion material still fails the capability gate.

Effekseer sprites always use the scene type. Direct-scene prewarm is queued until the first DrawInScene after Setup has supplied the current map transform. ParticleSprite::Update() does not advance the system while that request is pending; Effekseer then advances exactly one second and resets the wall-clock update origin, avoiding a second advance for time spent offscreen before the first draw. RefreshRenderTransform() then performs only an Effekseer zero-delta transform refresh before drawing; it never enters the forced first-tick path used by ordinary scheduled simulation.

The flag flows SparkQuadRenderer::GetDrawInScene()ParticleSystem::GetDrawInScene()ParticleSpriteFactory::LoadSprite. Model-bone particles (ModelInstance::RunParticle) are a separate path and ignore this attribute.

ModelSprite can also use the direct-to-scene path for visible map rendering when Render.ModelDirectDraw is enabled. With the default false value, map models stay on the cached atlas-sprite path: ModelSprite::Update() refreshes the model atlas and the sprite batch draws the atlas quad. With Render.ModelDirectDraw = true, ModelSprite::DrawInScene builds the same shared map view-proj basis as scene particles, bakes the map sprite’s logical root (scene_pos + raw scene depth) into the proj, and calls ModelInstance::DrawInScene. The model animation/skinning path is reused, but the old atlas-only camera tilt is skipped so the shared map VP owns the tilt once. DrawToAtlas is retained for preview and hit-test data and deliberately uses the entire automatically calculated logical frame, so the cached draw rectangle cannot cull a continuously updated direct pose. Model-bone SPARK particles use the active direct-scene proj with tilt_in_proj, so attached transparent particles render in the same world-space map frame and test against shared depth. Direct scene draws still disable the old model shadow pass because its shader math is atlas-space and needs a separate world-space rewrite.

World scale. Render.ModelProjFactor is the screen px per 3D world unit (= 32 = MAP_HEX_WIDTH), i.e. 1 world unit = 1 hex = 1 m — the single metric shared by 3D models and in-scene particles. So a scene-type system that emits within a radius of N units spans N hexes on the ground, matching direct-to-scene 3D models authored to the same scale.

Platform packages and BuildTools relationship

BuildTools/cmake/stages/Packages.cmake participates in package target generation. Platform package workflows decide which app/runtime artifacts are packaged, but renderer/backend availability still comes from configured source, compile definitions, third-party dependencies, and platform toolchains.

Keep these boundaries clear:

Do not document one embedding project’s generated target names as universal engine target names.

Frontend/rendering validation tests

Use Source/Tests/Test_Rendering.cpp as the smallest current engine-local test surface for renderer API behavior that should not require a real GPU. The test exercises the null renderer path, draw-buffer limits, texture creation, render-target creation, and invalid-argument checks. Pair it with platform-specific manual/debug validation when changing OpenGL/WebGL or Direct3D backend code.

Validation checklist

When changing frontend or rendering behavior, verify: