FOnline Engine
Current master GitHub
Documentation Docs/en/explanation/rendering/index.md

Frontend and Rendering

For the experimental Ogg/Theora decoder, fullscreen draw order, embedded RenderIface presentation, texture upload, aspect behavior, and visible acceptance boundary, see Video. This page owns the generic frontend and renderer infrastructure, not project cinematic policy.

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/.

Contract status

This page is a source-backed explanation of the current reusable frontend and rendering architecture. Project configuration settings, generated script APIs, resource formats, and serialized effect metadata keep the stability status assigned by their owning references. Native classes such as Application, Renderer, backend contexts, atlas allocators, and client draw managers are implementation surfaces unless the Public Contract Index says otherwise.

An embedding project owns its selected backends, authored effects, visual quality bar, target resolutions, performance budgets, and platform acceptance. The Engine owns backend selection semantics, render-resource contracts, matrix and depth conventions, and the reusable validation routes described here.

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

  • Source/Frontend/Application.h
  • Source/Frontend/Application.cpp
  • Source/Frontend/ApplicationInit.cpp
  • Source/Frontend/ApplicationHeadless.cpp
  • Source/Frontend/ApplicationStub.cpp
  • Source/Frontend/Rendering.h
  • Source/Frontend/Rendering.cpp
  • Source/Frontend/Rendering-OpenGL.cpp
  • Source/Frontend/Rendering-Direct3D.cpp
  • Source/Frontend/Rendering-Vulkan.cpp
  • Source/Frontend/Rendering-SDLGpu.cpp
  • Source/Frontend/Rendering-Null.cpp
  • Source/Common/Settings.inc
  • Source/Client/RenderTarget.h
  • Source/Client/RenderTarget.cpp
  • Source/Client/SpriteManager.h
  • Source/Client/SpriteManager.cpp
  • Source/Client/DefaultSprites.h
  • Source/Client/DefaultSprites.cpp
  • Source/Client/TextureAtlas.h
  • Source/Client/TextureAtlas.cpp
  • Source/Client/ModelSprites.h
  • Source/Client/ModelSprites.cpp
  • Source/Client/ModelSpriteLayout.h
  • Source/Client/ModelSpriteLayout.cpp
  • Source/Common/AnimationInfo.h
  • Source/Common/AnimationInfo.cpp
  • Source/Common/ModelBounds.h
  • Source/Common/ModelBounds.cpp
  • Source/Common/Geometry.h
  • Source/Common/Geometry.cpp
  • Source/Client/EffectManager.h
  • BuildTools/cmake/stages/Packages.cmake
  • Source/Tests/Test_Rendering.cpp
  • Source/Tests/Test_Geometry.cpp
  • Source/Tests/Test_ModelBaker.cpp
  • Source/Tests/Test_ImageBaker.cpp
  • Source/Tests/Test_TextureAtlas.cpp
  • Source/Tools/ImageBaker.cpp
  • Source/Tools/SpriteMeshing.cpp
  • Source/Tools/BakingReport.cpp
  • BuildTools/cmake/stages/Init.cmake

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.

Image source decoding belongs to the baker, not this frontend layer. At runtime, DefaultSpriteFactory turns the baked RGBA container into atlas-backed sprites; TextureAtlasManager allocates by AtlasType, and the renderer receives only texture-region uploads and draw data. See Image And Sprite Formats for the source-to-atlas contract and Sprite Root Motion for the narrower movement-driven use of per-frame offsets.

Bitmap-font descriptors are raw runtime resources, while their referenced PNG/TGA images still pass through normal image baking. FontManager parses the descriptor, uploads normal and bordered glyph regions to the font atlas, and submits text through SpriteManager; backend renderers do not interpret FOFNT/BMFont syntax or wrapping flags. See Font Formats And Text Layout for that boundary and the exact draw/measurement behavior.

Matrix convention

Engine render math uses one matrix convention:

  • mat44 is the GLM matrix type defined in Source/Common/Common.h.
  • Storage is column-major. Direct indexing is matrix[column][row]; translation is matrix[3].xyz; glm::value_ptr(matrix) can be copied into uniform buffers without transposing.
  • Algebra uses column vectors. Compose transforms as clip = Proj * View * Model * vec4(position, 1.0).
  • Shader code follows the same convention with ProjMatrix * vec4(...). Backend-specific differences belong inside the matrix constructors and shader cross-compilation path, not in ad-hoc row/column transposes at call sites.

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:

  • parse command-line and local configuration;
  • load app settings from config/cache sources;
  • initialize frontend globals once;
  • optionally locate and call baking support through FO_BakeResources when resource baking is needed by the current app flow;
  • prepare the app-level services used by clients, tools, and headless/test modes.

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.

After initialization, the client records the OS/version, CPU count, memory, SDL video driver, display mode and window metrics once in its log; a browser build also records its user agent. Each renderer names its selected device and driver, while degraded capabilities are warnings. These diagnostics describe the environment, not a guarantee that a particular GPU or platform passed release acceptance.

Application services

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

Application

Application owns process-level frontend state:

  • main and child windows;
  • active window selection;
  • frame boundaries through BeginFrame() and EndFrame();
  • render-window boundaries through BeginWindowRender() and EndWindowRender();
  • link opening and user-facing message/progress/choice dialogs;
  • main-loop callback registration;
  • quit requests and wait-for-quit synchronization;
  • touch gesture resolution and gamepad state refresh.

AppRender / IAppRender

AppRender is the application-owned facade over the selected Renderer. Client and tool code use it instead of downcasting to a concrete GPU backend. Its contract includes:

  • resource creation through CreateTexture(), CreateDrawBuffer(), and CreateEffect();
  • projection access through CreateOrthoMatrix() and GetProjMatrix();
  • target and depth-range selection through SetRenderTarget() and SetOrthoDepthRange();
  • target clearing through ClearRenderTarget();
  • clipping through EnableScissor() and DisableScissor();
  • render-target orientation through IsRenderTargetFlipped().

AppWindow::GetRender() exposes the render facade belonging to that real or virtual window. A consumer must keep operations on the owning window/engine instance; an embedded client’s render resources and settings are not host globals.

AppWindow / IAppWindow

Window responsibilities include:

  • size, screen size, position, display rect, focus, fullscreen state;
  • minimize, blink, always-on-top, title, input grabbing, and destruction;
  • distinguishing real OS windows from virtual windows (IsVirtual()) — host-composited embedded windows used by the multi-client host, whose render size is their own per-window virtual size rather than the shared logical screen size;
  • resolving a native WindowInternalHandle for render backends;
  • resolving a HeadlessWindowStub in headless/stub contexts.

AppInput / IAppInput

Input responsibilities include:

  • polling queued InputEvent values;
  • clearing and pushing events;
  • mouse position control;
  • screen keyboard enablement;
  • clipboard text;
  • gamepad state;
  • normalization of mouse, keyboard, wheel, touch tap, touch double-tap, touch scroll, and touch zoom events.

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:

  • reporting whether audio is enabled;
  • setting an audio stream callback;
  • converting audio formats;
  • mixing audio;
  • locking and unlocking the audio device around critical sections.

For the supported WAV/Ogg authoring profiles, AudioBaker delivery, effect and music lookup, playback handles and spatial updates, repeat, volume, and audible-validation contracts, use Audio and its generated audio reference. This page owns the platform abstraction; it does not duplicate game-facing audio authoring rules.

Headless and stub modes

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

  • Source/Frontend/ApplicationHeadless.cpp supports headless operation where a real visible client window is not required.
  • Source/Frontend/ApplicationStub.cpp provides stub implementations of render, input, audio, and window interfaces.

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.

Frame pacing

Desktop client, Mapper, and viewer loops use FrameBalancer (Source/Common/Common.h). Render.VSync = true disables its own pacing because presentation waits for the display. Otherwise a positive Render.FixedFPS caps the loop with precise_sleep regardless of Render.Sleep; an over-budget frame is paid back by shorter following waits, carrying at most one second of debt. Render.Sleep applies only when FixedFPS = 0: zero yields, a positive value uses coarse_sleep, and -1 leaves the loop unpaced. Source/Tests/Test_Common.cpp exercises the branches.

Rendering abstraction

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

  • RenderType — backend family selection.
  • EffectUsage — effect category used when loading/compiling effects.
  • RenderPrimitiveType — primitive topology used by draw buffers.
  • BlendFuncType and BlendEquationType — blend-state configuration read from effect config.
  • DepthVariantType and EFFECT_DEPTH_VARIANTS — the per-draw depth-state variant slot (see below).
  • Vertex2D and Vertex3D — vertex layouts used by sprite and model paths. For primitive batches uploaded through SpriteManager::DrawPoints, PosX/PosY are the draw-area-local pixel coordinates (with the draw_area scroll offset subtracted), and TexU/TexV carry PrimitivePoint::TexUV + draw_area.xy — that is, DrawPoints adds the draw-area top-left to whatever the caller authored. The intended idiom for world-stable per-fragment effects (dither, noise, gradient mapping) is to author the same constant TexUV on every vertex of a primitive batch — the absolute map-origin-anchored pixel position of the screen-anchor hex _screenRawHex. The fragment shader then reconstructs each fragment’s true absolute world pixel position as gl_FragCoord.xy + InTexCoord. Because every vertex carries the same constant, varying interpolation is degenerate (no barycentric rounding can crawl the noise), and the rasterizer’s per-pixel gl_FragCoord provides the spatial variation. This is robust against camera scroll, camera zoom, fan-triangle deformation from smooth sprite movement, and the from_hex.x parity sensitivity of GeometryHelper::GetHexOffset on offset-row hexagonal grids. MapView::LightFanToPrimitves authors it via GeometryHelper::GetHexOffset(mpos(0, 0), _screenRawHex). Primitive_Light.fofx consumes it (worldPixel = gl_FragCoord.xy + InTexCoord) to jitter the light’s edge taper with world-stable noise; other primitive shaders ignore InTexCoord and are unaffected. The light fan also carries the normalized radial distance in PrimitivePoint::PointPosZ (LightFanToPrimitves’ rim_dell: 0 at the center, ~1 at the rim) → InPosition.z; Primitive_Light.fofx reads it as Rim and smoothsteps the outer band (EdgeTaperStart..1.0) to zero so brightness rises gently from zero at the rim instead of ending in a hard constant-slope edge (which is very visible when the light moves).
  • RenderTexture — backend texture/render-target resource, with blocking and requested-region readback paths.
  • RenderTextureReadback — a pending CPU pixel snapshot; ImmediateTextureReadback holds pixels already available on the CPU.
  • RenderDrawBuffer — vertex/index storage uploaded to the backend.
  • RenderEffect — shader/effect object plus standard uniform/script-value buffers.
  • Renderer — backend interface implemented by concrete renderers.

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.

Reading a texture back

RenderTexture::GetTextureRegion(pos, size) is a blocking read. It is appropriate for initialization, screenshot/dump capture, and other callers that require pixels immediately, not repeated frame-path picking. Both readback paths require a positive, in-bounds rectangle.

RequestTextureRegion(pos, size) records a copy at the request point, observing draws and clears ordered before it. The returned RenderTextureReadback::TakePixels() polls without waiting: it returns std::nullopt until ready, then hands over the pixels exactly once. Taking them again throws. Pixel layout and row order match GetTextureRegion. Keep the request and its backend resources within the owning renderer/context lifetime.

The request itself is not universally non-blocking; the following fallbacks remain part of the current contract:

Backend Request and completion
Null CPU copy into ImmediateTextureReadback; ready immediately.
Direct3D 11 CopySubresourceRegion to owned staging storage; Map with D3D11_MAP_FLAG_DO_NOT_WAIT polls completion.
OpenGL Pixel-pack buffer plus glFenceSync; a zero-timeout glClientWaitSync polls completion. Requires sync, pixel-buffer and map-range support; Web and missing capabilities fall back to a blocking read at request time.
Vulkan Host-visible copy in the recording frame command buffer, between render passes; completed-frame tracking or the frame fence establishes readiness without a request-time submit/wait. Outside frame recording, the request falls back to the blocking read.
SDL_GPU Download transfer buffer in the current command buffer; a shared SDL_QueryGPUFence tracks completion. Only command buffers carrying readbacks acquire a submission fence; before submission the request is not ready.

ModelSprite::IsHitTest uses a CPU vector<bool> alpha mask of the model’s atlas picture, rather than reading a GPU pixel for each query. DrawToAtlas marks the mask stale; the next hit test refreshes it with at most one readback in flight, retaining the last completed mask until the next one arrives. Before the first mask is ready, hit testing returns false. A moving/animated silhouette can therefore lag the rendered pose, typically by a frame or two, without a guaranteed completion deadline. This is client presentation/picking evidence, not a server-authoritative hit or combat check. Ordinary AtlasSprite masks are built from source pixels at load time. RenderTargetManager no longer owns a last-pixel-pick cache or cache-invalidation API.

Sprite and model atlas geometry

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 logical frame is the tight projected extent of the current animation (aligned up to the sprite frame scale, not padded to a power of two), and the ground root sits at its exact projected pixel inside that frame ((-DrawRect.x, -DrawRect.y), exposed as ModelInstance::GetFramePivot()) rather than a fixed (DrawWidth / 2, 3 * DrawHeight / 4) fraction. A low or centre-origin creature therefore no longer reserves a tall empty frame above a fixed anchor. 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 view rectangle starts from the baked idle-priority bound. The live weighted pose never contributes: accumulating per-frame vertices would make root motion grow the name rectangle across a whole clip. Selected child models do contribute through their baked link envelopes. SelectModelViewBounds substitutes the complete current animation-plus-link envelope only when its projected top is lower than the idle view, so prone/corpse configurations can lower the name while raised weapons do not lift it. A project can still apply an authored NameOffset for presentation policy.

It does follow the animation downwards, which SelectModelViewBounds decides: when the active clip’s baked box tops out lower than the idle box after the same model-base transform and projection used by the layout, that clip’s box is taken whole. Comparing in projected space matters for imported models with RotX = +/-90, where source Z rather than source Y is screen-up. A corpse or a prone body would otherwise wear its name at standing height, far above itself; a lying pose is also wider than a standing one, so its top alone would not do. The asymmetry is the point — a raised weapon or an overhead swing tops out higher and is ignored, so names never rise with a swing. Both inputs are baked per clip, so the result is constant for a given animation and cannot drift within it.

The automatic logical frame uses a reusable 2x scratch render target. The model sprite factory reuses matching sizes in a least-recently-used cache with a soft budget of 8 x 1024 x 1024 colour pixels (32 MiB of RGBA storage, plus backend depth storage). A frame exceeding that budget occupies the cache alone. Sprite-cache cleanup releases every scratch target and clears the blit effect’s borrowed texture before retiring its owner; live sprite atlas allocations and shared model materials remain valid. This budget limits retained intermediate colour pixels, not total renderer memory or a fixed number of targets. The client unions baked active-animation bounds with the per-animation AABBs of selected direct non-particle child links whose parent clips are active. If no matching clip is active, or a link is nested under another child rig, the baked aggregate link envelope is used. It then projects the eight selected corners across the facing sweep. This keeps layer/equipment geometry in a facing-independent fixed frame without reserving a rigid attachment’s entire all-animation travel or performing a per-frame weighted-vertex walk. Only currently-emitting particle systems extend this envelope; a dormant effect reserves no frame and is absorbed by the bounded expansion pass if it starts emitting.

The dynamic draw envelope is ModelSpriteBounds::Rect: baked geometry, projected shadow, live particles, and any effect-forced full frame. It remains an internal renderer contract that sizes the frame and atlas crop.

Game.GetDrawCritter3dBounds exposes two stable layout rectangles. DrawRect is the conservative baked active-clip and selected-link envelope plus the projected shadow, intended for renderer-layout diagnostics. ViewRect is the semantic interface bound: it normally uses the idle-priority envelope and selects the complete lower/prone active clip where required. GUI portraits and world overlays use it instead of the larger draw envelope. The former separate pose rectangle was removed because it duplicated the same active model-occupancy contract as DrawRect without owning distinct behavior.

Already emitted world-space particles do not scale with the model. Fitting against the internal Rect would therefore create a feedback loop that shrinks the model while particles retain their size. Effects belong in the frame, not in an interface fit.

If that exact envelope needs a larger logical frame, the client expands the frame and rerenders before copying. Successive frame placements are merged as root-relative intervals, so adjacent pixel-rounded pivots or a live world-space particle cannot make an otherwise stable frame alternate forever. The interval anchor is signed: a tight frame may legitimately lie completely on one side of the model root, leaving its pivot outside the frame; the bounded retry loop still rejects a genuinely unbounded layout.

Layout helpers without a live GPU use AppRender::MIN_ATLAS_SIZE / FRAME_SCALE (MODEL_SPRITE_MAX_LOGICAL_FRAME_DIMENSION) as the portable logical ceiling; the bake host’s atlas limit is not the game device. A model sprite’s scratch texture is capped by Render.ModelSpriteMaxTextureWidth / Height and the current machine’s atlas. An envelope beyond that cap is drawn cropped, while the bounded retry loop still rejects a layout that cannot converge inside it. ModelInstance::SetupFrame also rejects a logical draw_size * FRAME_SCALE above the current machine’s AppRender::MAX_ATLAS_WIDTH / MAX_ATLAS_HEIGHT, naming the model and both sizes before an anonymous device texture-allocation failure can occur. Meshes disabled by the model’s own default .fo3d link contribute to neither drawn nor pose bounds; runtime honors that default exactly as it honors layer/attachment DisableMesh, keeping runtime geometry inside the baked layout budget.

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 PNG 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. Release clears the mesh observer and returns the rectangle directly to the free list in constant time. The list does not coalesce eagerly: on a placement miss, DefragmentFreeRectangles rebuilds the exact maximal set from live allocations before opening a new page. A contained-rectangle prune runs only after growth well beyond the previous pruned size and indexes keepers by coarse atlas cells, avoiding hot-path full scans. No surviving sprite, pixels, or UVs move; this changes no settings or resource serialization.

TextureAtlasManager::CleanupAtlases() deletes empty pages and their manager-owned render targets, including OneImage pages. SpriteManager invokes it after cache eviction, and new-page creation also performs cleanup. Any page with a live allocation remains valid: cleanup neither moves pixels nor changes UVs. ExpiredOneImageAtlasReleasesRenderTarget and AtlasCleanupReleasesOnlyEmptyPages pin the empty/live boundary.

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

The compiled RenderType inventory and selection surface are:

RenderType Selection Current contract
Null Render.NullRenderer or a headless/stub path Implemented CPU-only validation backend; no visible GPU output.
OpenGL Render.ForceOpenGL or the last automatic GPU choice when compiled Implemented native OpenGL/OpenGL ES/WebGL backend; render targets are flipped.
Direct3D Render.ForceDirect3D or the first automatic Windows choice Implemented Direct3D 11 backend; render targets are not flipped.
Metal Render.ForceMetal Direct Metal is a placeholder: the enum/platform flag exists, but forcing it throws AppInitException; there is no Metal_Renderer.
Vulkan Render.ForceVulkan or the automatic choice before OpenGL when no earlier backend was created Implemented dynamically loaded Vulkan backend; render targets are not flipped.
SDLGpu Render.ForceSDLGpu, optionally with Render.SDLGpuDriver Implemented explicit opt-in SDL_GPU backend over Vulkan/Metal/D3D12 drivers; render targets are not flipped.

Application first honors the force selectors, then follows the compiled automatic order. Selection is not a health-probing fallback chain: after it creates a backend, initialization failure is reported and the application does not retry another backend. Keep every force selector mutually exclusive. Render.ForceSDLGpu never becomes an automatic default. Direct Metal remains unavailable; on a supported Apple build, the implemented Metal route is SDL_GPU with Render.ForceSDLGpu = True and Render.SDLGpuDriver = metal.

Vulkan and SDL_GPU are enabled by default for non-headless, non-Web builds and can be removed with FO_DISABLE_VULKAN and FO_DISABLE_SDL_GPU. Their build uses vendored SDL headers/drivers rather than an external Vulkan SDK. OpenGL, Direct3D, and platform flags are selected by the platform branch in BuildTools/cmake/stages/Init.cmake.

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:

  • creates an SDL/OpenGL or WebGL context depending on platform;
  • loads and validates required GL entry points/extensions;
  • sizes the atlas against AppRender::MAX_ATLAS_SIZE and backend limits;
  • creates textures, draw buffers, and effects;
  • compiles/loads vertex and fragment shader content through the effect loader;
  • reports render-target textures as vertically flipped (IsRenderTargetFlipped() == true);
  • uniform blocks go through one shared bump-allocated UBO (ES 3.0 / GL 3.1 compatible): per draw, every shader-required block — default-initialized to zero when the engine did not fill it, since a skipped block would otherwise keep a binding into a region whose storage dies at the per-frame orphan in Present() — is packed into a contiguous region, uploaded with a single glBufferSubData, and bound per pass with glBindBufferRange (replaces up to ~8 tiny per-draw glBufferData re-specifications of per-effect UBOs);
  • SetRenderTarget elides redundant re-selects of the already-applied target (bind, viewport, aspect-fit and ortho recompute are skipped); the cache is invalidated on resize and on destruction of the cached texture, and mid-frame texture creation restores the current target’s framebuffer rather than unconditionally the base one.

OpenGL is the path to inspect for WebAssembly/WebGL behavior. Pair renderer changes with Web Build, Packaging, and Browser Debugging validation.

Direct3D renderer

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

It loads baked -dxbc shader bytecode; the client does not compile HLSL at runtime or require d3dcompiler_47.dll. The normal hardware floor is feature level 10.0. A 2D-only build may accept feature level 9.3 when effects were baked with Baking.Direct3DLevel9Shaders; 3D-enabled builds never create a 9.3 device. That path limits atlases to 4096 pixels and uses point-list draws without an index buffer. Levels 9.1 and 9.2 are unsupported.

The device request tries feature level 11.1 first. If the older Direct3D 11.0 runtime rejects that list with E_INVALIDARG (notably Windows 7 without its platform update), the engine retries the same hardware or WARP request without 11.1. The swap chain is then created from the DXGI factory that owns the chosen device’s adapter, not from a separately created factory; DXGI 1.1 rejects the latter pairing. Other device-creation failures retain their normal failure path. The log records the chosen feature level and, when available, the DXGI adapter, vendor/device IDs, user-mode driver version and dedicated video memory.

Important behaviors:

  • creates D3D device/swap-chain/render-target resources;
  • leaves the refresh rate unspecified for the windowed swap chain so DXGI follows the desktop compositor instead of requiring one hard-coded display mode;
  • creates textures, staging textures, draw buffers, constant buffers, and effects;
  • loads vertex/pixel shader content through the effect loader;
  • handles resize by recreating backbuffer/depth resources;
  • reports render-target textures as not flipped (IsRenderTargetFlipped() == false).

A missing GPU is refused rather than papered over. When no hardware device is available, DXGI will happily hand back the WARP software rasterizer, which draws every frame on the CPU: the client then starts, runs, and cannot be played. That substitution is now an explicit choice — Render.AllowSoftwareRenderer (default off) permits it for diagnostics or a headless machine, and the log names the device as Warp so a slow session is never a mystery. With the setting off, device creation fails loudly instead of producing a playable-looking client that is not one.

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

Direct Metal placeholder

FO_HAVE_METAL, RenderType::Metal, Render.ForceMetal, and the SDL Metal window flag exist in the platform/application surface, but the Engine has no direct Metal_Renderer implementation. Render.ForceMetal = True therefore fails immediately with NotImplementedException; it is not a supported rendering configuration and must not be advertised or used as release evidence.

SDL_GPU’s Metal driver is a separate implemented backend. Select it with Render.ForceSDLGpu = True and Render.SDLGpuDriver = metal, then apply the same visible, shader, interaction, and performance gates as for its Vulkan and D3D12 drivers.

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:

  • Single queue, two frames in flight. The context owns VULKAN_FRAMES_IN_FLIGHT (= 2) frame slots, each bundling a command buffer, an in-flight fence, an acquire semaphore, a descriptor pool, a persistently-mapped uniform bump buffer, a texture-staging ring and a deferred-destroy queue. BeginFrame() advances the slot, waits its fence (normally instant — this replaces the old full vkQueueWaitIdle, so the CPU records frame N while the GPU renders frame N-1), flushes the slot’s deferred destroys, resets its descriptor pool, points the context’s current-slot aliases (CommandBuffer, FrameDescriptorPool, FrameUniformBuffer, …) at it, acquires a swapchain image, clears it, and begins the render pass; EndFrame() ends the pass, submits (signaling the slot fence and the acquired image’s render-complete semaphore), and presents. Render-complete semaphores are per swapchain image, so a semaphore is never re-signaled while the presentation engine may still wait on it; acquire semaphores are per slot. A fence signal implies completion of all earlier submissions on the queue, which is the single correctness anchor for every per-slot resource reuse.
  • Deferred destroys are per frame slot. The typed Destroy*Safe(...) helpers enqueue into the current slot’s queue; the queue is flushed right after that slot’s fence is waited, by which point both in-flight frames that could reference the resource are provably complete. They intentionally have distinct names because Vulkan non-dispatchable handle typedefs collapse to the same integer type on 32-bit targets. All Vulkan handles use VK_NULL_HANDLE rather than nullptr, so the same code remains valid for both pointer-backed and integer-backed handle ABIs. Swapchain recreation paths settle the device (vkDeviceWaitIdle), flush all queues wholesale and rebuild every sync object.
  • Texture uploads and requested readbacks record into the frame command buffer; blocking readbacks flush it. UpdateTextureRegion suspends the render pass and records barrier → copy → barrier in the frame buffer, preserving the ordering of prior clears and uploads through the slot’s staging ring. RequestTextureRegion similarly records its copy between render passes without a mid-frame submit/wait; TakePixels checks the completed-frame index or submission fence. GetTextureRegion still calls FlushFrameCommandBufferMidFrame() (submit the recorded prefix, wait idle, resume recording) before its immediate staging copy. Uploads and readback requests outside frame recording use the immediate path. Keep these ordering and readiness boundaries when adding immediate-queue operations.
  • Dynamic geometry goes through per-draw-buffer, per-frame-slot ring pools. Every dynamic DrawBuffer::Upload takes the next buffer of the draw buffer’s growable ring of persistently-mapped HOST_VISIBLE buffers for the current frame slot (one ring buffer per upload within a frame, so earlier draws pending in the frame command buffer keep their geometry snapshots). A ring resets on its first acquire in a new frame; its slot’s in-flight fence was waited by then, so every buffer in it is GPU-free. Ring buffers only reallocate on capacity growth, so steady-state uploads are pure memcpy with zero vkCreateBuffer/vkAllocateMemory/vkFreeMemory traffic (per-upload buffer churn plus the matching deferred-destroy sweep previously dominated the backend’s CPU frame cost ~25 ms/frame in crowd scenes). Static buffers keep the one-off staging copy to device-local memory.
  • Shaders are baked with highp floats. The effect baker emits ES shaders with precision highp float. mediump would become SPIR-V RelaxedPrecision, which desktop GL/D3D silently ignore but NVIDIA Vulkan drivers honor as FP16 — large uniform values (frame time in seconds, world-anchored UVs) then overflow half-float range (max 65504) and shaders that consume them (e.g. time-driven weather/atmosphere post-processing) collapse to black on Vulkan only.
  • Back-buffer target metrics follow resizes without SetRenderTarget. The letterboxed viewport, logical target size and projection for back-buffer rendering are recomputed by ApplySwapchainTargetMetrics() — from SetRenderTarget(nullptr), from OnResizeWindow() and after a deferred swapchain recreation when the back buffer is the active target. The server host UI renders ImGui straight into the swapchain and never calls SetRenderTarget, so without the resize-path refresh a post-init window/logical-size change leaves a stale projection and the UI renders shrunken into a corner (Direct3D gets the same refresh by ending its OnResizeWindow with SetRenderTarget(nullptr)).
  • Two fixed descriptor set layouts. Set 0 holds uniform buffers and set 1 holds combined image samplers (each layout declares a fixed number of bindings). Per draw, the backend allocates one descriptor set per layout from a per-frame descriptor pool (reset every BeginFrame()), and writes uniform data into a single host-visible bump-allocated uniform buffer. The binding index used within each set is the effect’s reflected binding from the baked EffectInfo.
  • Authored .fofx descriptor-set contract. Because of the two-set layout, every effect’s shader must declare uniform blocks with layout(set = 0, binding = N, std140) and samplers with layout(set = 1, binding = N). An effect that omits the set qualifier defaults every resource to set 0, which collides samplers with uniform buffers and produces VkDescriptorType mismatch (VUID-…-layout-07990) and “descriptor never updated” (VUID-vkCmdDrawIndexed-None-08114) validation errors, leading to device loss. Engine core/embedded effects follow this convention; embedding-project effects must follow it too. (OpenGL/Direct3D ignore the set qualifier because they bind UBOs and samplers in separate namespaces, so this only surfaces on Vulkan.)
  • Uniform completeness. Unlike OpenGL/D3D, where an unbound UBO retains its last value, a per-draw descriptor set must write every uniform block the shader uses. The backend default-initializes any required-but-unset standard buffer (egg, sprite-border, time, random, script, camera, …) to zero before upload so no shader reads an unwritten descriptor.
  • Surface format. The swapchain uses VK_FORMAT_B8G8R8A8_UNORM / SRGB_NONLINEAR, verified against vkGetPhysicalDeviceSurfaceFormatsKHR. The single render pass is shared by the swapchain framebuffers and all texture render targets, so texture render targets use the same color format for render-pass compatibility. CPU pixel upload/readback swizzles R↔B to match this format.
  • Present mode honors Render.VSync. VSync = true (or a surface with no better mode) uses VK_PRESENT_MODE_FIFO_KHR; with VSync off the swapchain prefers IMMEDIATE (uncapped, possible tearing), then MAILBOX (uncapped, no tearing), matching how the other backends honor the setting in their present paths. The chosen mode is logged at swapchain (re)creation (Vulkan swapchain present mode: …). A hardcoded FIFO would silently vsync-lock the backend and make cross-renderer frame-rate comparisons meaningless.
  • Orientation. Render-target textures are reported as not flipped (IsRenderTargetFlipped() == false, like Direct3D). The projection matrices are identical to the other backends (Y-up ortho); Vulkan’s Y-down clip space is compensated by a negative-height viewport (core since Vulkan 1.1 — the instance requests VK_API_VERSION_1_1). Keeping the matrices identical matters beyond the GPU: GetProjMatrix() feeds engine-side 3D model camera math (ModelSprites/ModelInstance), and a backend-specific Y-negated matrix breaks that CPU-side placement (3D models render clipped). The negative viewport flips screen-space winding exactly like the old Y-negated matrix did, so pipeline front-face settings are unaffected.
  • Point primitives. Effect shaders are cross-compiled to HLSL/MSL via spirv-cross, which cannot express gl_PointSize, so a POINT_LIST topology (which Vulkan requires PointSize for) is mapped to TRIANGLE_LIST. Point primitives are not used by current content; revisit if real point rendering is needed.
  • Physical device selection. The backend prefers a discrete GPU that exposes a graphics+present queue family for the surface and the swapchain extension, instead of blindly taking the first enumerated device.
  • Validation. When Render.RenderDebug is set (or in a debug build) and the VK_LAYER_KHRONOS_validation layer is available, the backend enables it and routes layer messages to the log as [VkLayer/…] through a VK_EXT_debug_utils messenger. Use this for backend validation; a correct change should run with zero validation errors.

Vulkan changes should be validated on a machine with a working Vulkan runtime and, for validation output, the Khronos validation layer. Run a visible client with Render.ForceVulkan=True Render.RenderDebug=True and confirm the log has zero [VkLayer/... errors. A Vulkan SDK is not a build prerequisite for the Engine itself.

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:

  • Immediate-mode contract on explicit passes. Context keeps at most one render/copy pass open, opens passes lazily, delays clears until a load-op, and uploads through cycled transfer buffers. Blocking GetTextureRegion submits and waits a fence. RequestTextureRegion materializes pending clears and records a download in the current command buffer without that wait; Present() acquires a shared submission fence only for command buffers with pending readbacks. TakePixels() polls the fence, and a blocking flush marks readbacks on its completed buffer ready.
  • Backbuffer proxy. The window backbuffer is never rendered directly: SetRenderTarget(nullptr) targets an RGBA8 proxy texture (letterbox viewport math shared with the other backends) and Present() blits the proxy to the acquired swapchain texture, which keeps mid-frame flushes safe and pipeline color formats uniform.
  • Per-effect pipeline cache. Graphics pipelines are immutable state objects cached per effect, keyed by pass, topology, depth-target presence, DisableBlending, and DisableCulling.
  • Consumes the SDL-convention baked flavors, not the native -spv. SDL_GPU mandates a per-stage descriptor convention (vertex samplers = set 0 / UBOs = set 1, fragment samplers = set 2 / UBOs = set 3) that differs from the native Vulkan renderer’s 2-set convention (UBO = set 0, sampler = set 1). So the effect baker emits an extra -spv_sdl flavor — the native SPIR-V with its descriptor decorations rewritten to the SDL convention — plus SDL-remapped -msl_* and an [EffectInfoSdl] metadata section (per-stage sampler/UBO counts + dense slot indices). The native -spv (consumed by Rendering-Vulkan) is untouched. The backend picks -spv_sdl for the Vulkan driver or -msl_* for the Metal driver via SDL_GetGPUShaderFormats, and reads the per-stage slots from [EffectInfoSdl].
  • Push-style uniforms. Uniform data is pushed with SDL_PushGPU{Vertex,Fragment}UniformData (at most 4 slots per stage) and re-pushed on every draw; the effect’s public uniform optionals keep their last value to emulate the persistent-buffer semantics of the other backends. The 4-UBO-per-stage limit is enforced by the baker at bake time.
  • ProjBuf/MainTexBuf are caller-owned when set, renderer-derived otherwise. DrawBuffer auto-fills ProjBuf from the renderer’s current 2D ortho and MainTexBuf from the bound texture size only when the caller has not already supplied them (_needX && !X.has_value()), then reset()s just those two after the draw so the next 2D draw re-derives them. This mirrors the native Vulkan backend and is load-bearing for 3D: ModelSprites/ModelInstance set ProjBuf externally to the per-frame model projection before drawing a critter model to its atlas — unconditionally overwriting it with the 2D ortho projects the skinned mesh off-screen, so nothing rasterizes into the model atlas and 3D critters render as name-plates only (the “characters not drawn in SDL” bug). The other externally fed buffers (EggBuf, ModelBuf, …) are likewise only auto-derived behind !has_value() and keep their last value across draws.
  • Shares the engine-wide black-map fixes. Because it reuses the same baked SPIR-V pipeline (baked with precision highp float) and the same epoch-based shader-time wrap in EffectManager::PerFrameEffectUpdate, the SDL_GPU backend inherits both Vulkan-only fixes (half-float overflow and sin(large accumulated time) NaN) and does not reproduce the black-map failure.
  • Point primitives, orientation, depth. POINT_LIST is remapped to TRIANGLE_LIST (shaders lack gl_PointSize), mirroring the native Vulkan renderer. IsRenderTargetFlipped() is false and the ortho matrix uses the [0,1] depth convention. Depth targets use D24_UNORM when supported, otherwise D32_FLOAT. Max atlas size is fixed at 4096 (SDL_GPU exposes no texture-size query).

Validate SDL_GPU changes with a client scene launch under Render.ForceSDLGpu=True Render.RenderDebug=True (Vulkan validation on the Vulkan driver), confirming a visible map and GUI with no validation errors, side by side against the default backend. Pin Render.SDLGpuDriver when the acceptance claim is driver-specific.

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:

  • create render targets with optional depth and linear filtering;
  • allocate backend RenderTexture objects through IAppRender::CreateTexture();
  • preserve/restore the previous backend render target while allocating and clearing a new target;
  • maintain a render-target stack through PushRenderTarget() and PopRenderTarget();
  • clear the current render target;
  • resize render targets;
  • delete render targets and clear the stack;
  • dump render-target textures for debugging.

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

The manager owns every target it creates; a MapView borrows its map, light and indoor-mask targets and releases all three in OnDestroySelf(). Destruction first flushes queued sprite draws while their textures are alive. Before deleting the indoor mask, EffectManager::ClearIndoorMaskTexture() clears only matching IndoorMaskTex borrows across all cached effects, including effects no longer selected for map flushing. Another live map’s mask and the surrounding target stack remain intact. GetRenderTargetCount() counts manager-owned targets for lifecycle diagnostics, not backend memory or driver residency.

Source/Tests/Test_ClientEntityLifetime.cpp pins repeated release under default, disabled-mask and direct-draw settings (MapViewRenderTargetsAreReleasedOnDestroy) plus queued draws, cached effects, another live map and an outer target (MapViewDestroyClearsOnlyItsCachedIndoorMaskReferences). Hardware memory and visible map-transition acceptance remain separate checks.

MapView::DestroyRenderTargets() is shared by unload and the construction-failure guard; retained script handles do not delay target retirement. ClientMapUnloadReleasesRenderTargetsWithRetainedHandles and ClientMapConstructionFailureReleasesRenderTargets cover both boundaries. Immediate native buffer/pool retirement is described in client lifetime.

When a local map is loaded, View.MapRenderTargetScale fixes the map, light, and indoor-mask target dimensions to the logical screen size multiplied by that scale. The engine clamps the size to the renderer’s texture limit; views beyond the resulting target use multiple chunks.

The light target is composited only over the current chunk’s drawn area, expanded by render-target padding and a one-eighth-chunk margin for shake and refractive sampling. MapView::GetMapCompositeRect supplies that bound to both ordinary FlushLight and the custom fog-slot path. In a zoomed-in view this avoids flushing the entire oversized light target while retaining the edge pixels the effects may read.

Gui::CheckHit caches its boolean result for the current Game.FrameTime and query position because cursor drawing, zoom, and movement can ask for the same point in one frame while FindHit walks every screen tree. GUI activation, geometry, ordering, scroll, crop, transparent-hit image, and hittability changes invalidate the cache. Resolution and language refresh invalidate through _RefreshPositionRecursive; setters that receive an unchanged value return without invalidating. The internal _Move helper deliberately does not invalidate by itself because Draw uses a temporary _Move pair every frame, so direct callers that change persistent layout invalidate explicitly.

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:

  • Logical screen size — Settings.View.ScreenWidth/ScreenHeight. This is the coordinate space the game renders in: the main render target _rtMain (SpriteManager), the projection matrix, and the GUI/ImGui display size all use it.
  • Backbuffer (framebuffer) size — the actual output surface: the OS window’s pixel size in windowed mode, the monitor size in fullscreen, or an embedded client’s virtual render texture in the multi-client host.

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:

  • keeps physical virtual-window size (_virtualSize, GetSize()) separate from logical client resolution (_virtualScreenSize, GetScreenSize());
  • renders game content into _rtMain at the logical size, then aspect-fits that render target into _virtualRenderTex at the physical virtual-window size;
  • is composited by the host: ServerApp draws the client’s virtual render texture aspect-fitted and centered into a per-client display rect (SetDisplayRect), and maps input back through that rect, _virtualSize, and the same aspect-fit content rect used for rendering so black bars do not skew client-local mouse coordinates.

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

Use Effect Format and the generated effect-format reference for .fofx sections, pass/render state, vertex inputs, built-in resources, descriptor conventions, baked artifacts, path-cache identity, script-value lifetime, and authoring validation. This page owns how those effects participate in the frontend/render pipeline.

RenderEffect owns standard buffers used by render paths:

  • projection/main texture data;
  • transparent egg parameters;
  • sprite border parameters;
  • time/random/script values;
  • camera/model/model-texture/model-animation data.

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:

  • effect config parsing in Rendering.cpp;
  • backend shader loading/drawing in the affected OpenGL, Direct3D, Vulkan, or SDL_GPU implementation;
  • client effect selection/update code in EffectManager;
  • map/client draw sequencing in MapView or SpriteManager.

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 optional Direct3D feature level 9.3 profile: no gl_FragCoord / position-semantic reads, screen-space derivatives (dFdx/dFdy/fwidth), texture-size queries, or dynamic array/vector indexing. The baker emits GLSL 330, GLSL ES 300, Metal, and HLSL Shader Model 4.0, then compiles HLSL to -dxbc bytecode; an opt-in 9.3 bake adds Aon9 for non-model effects. Keep an engine base effect minimal, as its Profile: minimal header records. The standard Direct3D path remains feature level 10.0 or newer.

Default slot → effect mapping (Source/Client/EffectManager.cpp): Font/Iface/Generic/Critter/Rain → 2D_Default; Roof/Tile/Flat → 2D_NoDepth; Primitive → Primitive_Default; Light → Primitive_Light; Fog → Primitive_Fog; FlushPrimitive/FlushMap/FlushLight/FlushFog/FlushRenderTarget → the matching Flush_*; SkinnedModel → 3D_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:

  • DepthWrite (default True) → RenderEffect::_depthWrite[pass] (depth write mask).
  • DepthFunc (default Always) → RenderEffect::_depthFunc[pass] (DepthFuncType: Always/Never/Less/LessEqual/Equal/GreaterEqual/Greater/NotEqual). Both Rendering-OpenGL.cpp and Rendering-Direct3D.cpp translate it (ConvertDepthFunc) and the backends diverge in NDC-Z ([-1,1] GL/GL-ES vs [0,1] D3D), so depth-dependent effects must be validated on both.
  • DepthVariants (default False) → RenderEffect::_depthVariants. Opting in lets a single draw pick a depth state other than the declared one, through the RenderEffect::DepthVariant input field (DepthVariantType: FromEffect/TestWrite/TestNoWrite/NoTestWrite/NoTestNoWrite, where Test reuses the effect’s own DepthFunc and NoTest replaces it with Always). This exists because transparent geometry can carry per-item depth intent that is orthogonal to the shader — a particle system stores a depth-test and a depth-write flag per emitter node — and encoding that as separate effect files multiplies with every other shader feature.

    The resolved state is addressed by a slot (ResolveDepthVariantSlot, EFFECT_DEPTH_VARIANTS = 4) so the backends can keep depth state in pre-built device objects: Rendering-Direct3D.cpp builds one ID3D11DepthStencilState per used slot, Rendering-Vulkan.cpp one pipeline per (pass, primitive, blend, slot), Rendering-SDLGpu.cpp folds the slot into its lazy pipeline-cache key, and Rendering-OpenGL.cpp simply issues the resolved state. The resolver rejects a draw whose resolved slot was not built, so an unsupported override cannot silently disable depth in Direct3D, reuse a stale Vulkan pipeline, or diverge from the immediate OpenGL state. Because the slot encodes the resolved state rather than the requested variant, an effect that declares no variants can still use an equivalent requested state when it lands on the one slot its own state built. RenderEffect::CanBatch compares DepthVariant, so draws that resolve differently are not merged.

The scene background snapshot

Content that refracts what is behind it cannot read the render target it is drawing into, so SpriteManager::AcquireSceneBackground() copies the current target into a target-sized render target of its own and hands back that texture. The copy is lazy and at most once per direct-draw replay: DrawSprites invalidates the snapshot before replaying its direct-draw sprites, and the copy happens only when a draw actually asks, so a frame with nothing refracting never pays for it. The blit is opaque — a refracting draw wants the colours behind it, not another blend of them.

A refracting effect reads the copy through RenderEffect::BackgroundTex, the second-texture slot alongside MainTex and IndoorMaskTex, bound the same way in all four backends and declared in a shader as BackgroundTex. The snapshot keeps whatever orientation its source target has, so the shader flips the screen-space lookup for a flipped one rather than the copy being re-oriented.

Particle runtimes reach it through ParticleRuntimeServices::SceneBackgroundProvider, a callback pulled at the draw that needs it rather than a value pushed into every Setup. An Unavailable result means there is no scene to refract — an ordinary offscreen atlas, for instance — and the runtime fails closed instead of refracting an empty target. Deferred is reserved for an auxiliary preview of a system that has a later direct-scene draw: that one draw skips the distortion node without retiring the system, and the scene draw asks the provider again.

Model-attached particle runtimes receive the same provider only while ModelInstance::DrawInScene is active. A direct model still refreshes one auxiliary atlas frame for preview and hit testing; when Render.ModelDirectDraw is enabled, that offscreen refresh returns Deferred, so the distortion attachment survives and samples the current scene snapshot during the subsequent direct replay. A normal atlas-rendered model returns Unavailable and keeps the fail-closed contract because it has no later scene draw.

Per-draw face culling

Which faces a draw discards is a property of the draw, not of the effect’s usage: RenderEffect::CullMode (CullModeType: None/Back/Front, zero-initialised to None) is set by the caller, and RenderEffect::CanBatch compares it so draws that cull differently are not merged. 3D models set Back (or None when the model disables culling); a particle runtime whose format stores a culling mode per emitter node — Effekseer has Front, Back and Double — sets the matching mode per draw.

Backends that bake the rasterizer state into a device object build one per mode the effect can be drawn with, so an effect declares CullVariants = True in its [Effect] block to pay for them: Rendering-Direct3D.cpp builds one ID3D11RasterizerState per mode, Rendering-Vulkan.cpp one pipeline per (pass, primitive, blend, depth slot, cull mode), Rendering-SDLGpu.cpp folds the mode into its lazy pipeline-cache key, and Rendering-OpenGL.cpp issues glCullFace directly. RenderEffect::ResolveCullMode() is the single choke point: it throws when a draw asks for a mode the effect never built, because a backend that quietly skipped the missing object would drop the draw (Vulkan) or fall back to a different mode (Direct3D) instead of reporting the mismatch. Counter-clockwise is the front face on every backend.

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):

  • Screen-space quads (GUI, fonts, render-target blits, and non-map sprite effects) initialize Vertex2D::PosZ to 0.0f; map sprites may start from the same atlas data, but SpriteManager overwrites their Z before flushing them into _rtMap.
  • Standing map sprites (Item/Critter) write the shared depth buffer but do not test it (DepthFunc = Always in 2D_Default.fofx / 2D_WithoutEgg.fofx): the write is what lets direct-draw particles / 3D models occlude against sprites, while sprite-vs-sprite occlusion is decided purely by the painter order. That is exact rather than approximate, because every standing sprite’s depth plane shares one gradient (ProjectMapYToVerticalDepth): parallel planes never intersect, so “which sprite is in front” is a whole-sprite fact, and ordering by the anchor depth reproduces a per-pixel LessEqual result pixel for pixel — without the interpolation noise that made coincident planes (hexes on the same screen row) flip the winner per pixel row and show up as horizontal z-fighting stripes on far rows of large maps. This is why MapSpriteList::MakeDrawOrderPos sorts standing sprites by GeometryHelper::GetHexScreenRow (the row of GetHexPos().y, i.e. the equivalence class of equal ground depth — hexes related by +2X/−1Y) instead of the hex row: the hex row disagrees with depth order (e.g. hy−1, hx+4 is nearer yet sorts earlier) and would make the painter order wrong for overlapping large scenery. Pinned by Source/Tests/Test_Geometry.cpp (GetHexScreenRow). The trade-off is that a flat depth-writing layer no longer clips a standing sprite per pixel (their planes do intersect): a flattened corpse drawn in an earlier layer is covered by any overlapping standing sprite regardless of depth. Their per-vertex depth is the vertical-billboard proxy (get_map_sprite_proj → GetHexWorldPos/ProjectWorldToMap, anchored on the sprite root; the MapView::InitView view layout reproduces GetHexPos.y exactly, so the rendered screen position and the depth basis agree). The depth/sort anchor is the object’s LOGICAL root, not the bitmap bottom-center. For an item the proto Offset is the bottom-center→root vector (a tree’s trunk): it still positions the bitmap through MapSprite::_pSprOffset (so the visual, lighting and MeasureMapBorders are unchanged), but it is also kept as a separate static root offset (HexView::_rootOffset → MapSprite::_pRootOffset) that the depth proxy subtracts — in GetMapRootOffset() for sprite_proj.z and from scene_pos_y for the per-vertex reference — so a tall sprite anchors on its trunk instead of Offset pixels below it. Without this, the tree’s depth anchored at the bitmap bottom (too far south/near) and it wrongly occluded a critter standing in front of it. Critters carry no proto Offset (their root comes from the sprite anchor), so their _rootOffset is zero. They are the only map sprite layer that participates in the depth buffer; every flat/background layer (floor tiles, roofs, flat ground overlays) is painter-only and depth-inert — neither writes nor tests (see below). MapSprite receives critter/item Elevation; positive elevation shifts the sprite upward in screen Y and increases the same world-Z depth. MapSprite::HexOffset plus runtime sprite/tweak offsets are projected along the ground plane before depth is computed, so sub-hex movement changes both screen position and 3D depth continuously; viewport-only field.Offset is not part of world depth. The intrinsic Sprite::Offset is different: it defines which pixel inside the atlas quad is the logical root on the ground, so vertical depth and direct-to-scene anchors use that root instead of assuming the bitmap’s lower center. Floor tile layers (DrawOrderType::Tile..Tile4) and flat ground overlays (FlatItemPreLight/HexGrid pre-light, and DeadCritter/FlatItemAfterLight post-light — the layers below NormalBegin) are upright background sprites: they keep their atlas-provided screen-space XY/UV and never touch the shared depth buffer. Entity sprites choose the no-depth effect at the entity level: tiles and roofs resolve Effects.Tile / Effects.Roof; flat items resolve Effects.Flat (ItemHexView::Init, by GetDrawFlatten()) — all three → 2D_NoDepth.fofx. Script-created MapSpriteHolder sprites have no entity-level effect handle, so MapView supplies the pass default effect to SpriteManager::DrawSprites from the draw-order segment before batching: Tile..PreLight → Effects.Tile, AfterLight..FlatEnd → Effects.Flat, Roof..RoofParticles → Effects.Roof, and normal layers → Effects.Generic. Item draw order is decided by GetDrawFlatten(), never by IsScenery/IsWall: upright → Item (the default), flat → FlatItemPreLight if GetStatic() (drawn pre-light) else FlatItemAfterLight (post-light). The former Scenery/Item (and FlatScenery/FlatItem) layers were merged, so upright items on one hex no longer force scenery behind items by class — they draw in add order (the MapSprite::_globalPos tiebreaker once _drawOrderPos ties on the merged layer + hex). (Dead critters drawn flattened keep Effects.Critter and still write depth, but since standing sprites no longer depth-test, a corpse cannot clip a standing sprite — it is covered by draw order.) All of these (DepthWrite = False + DepthFunc = Always) are drawn before the standing sprites and fully painter-sorted, so they cannot z-fight (coplanar layers), seam (abutting sprites), or clip a standing sprite’s feet / a 3D model — they need no per-vertex depth, no ground-plane projection, and no per-layer bias. Standing sprite layers (Item, Critter) keep atlas XY/UV but write per-vertex PosZ through GeometryHelper::ProjectMapYToVerticalDepth, so they behave like vertical planes standing on their ground anchor; they carry no draw-order depth bias, because one would pull the vertical plane toward the camera and move the particle-occlusion line off the logical root point. The only remaining depth-bias user is the direct-draw path (particles / 3D models replayed at the end of each sprite pass): SpriteManager divides a half-pixel depth budget (MAP_LAYER_DEPTH_BIAS) by DrawOrderType::Last + 1 and gives each direct-draw sprite a single such step above its world depth, keeping it below the subpixel snapping threshold (see Direct-to-scene sprites). Both Core and Embedded 2D_Default.fofx must project the full InPosition.xyz; if an override flattens to InPosition.xy, 0.0, particles have no useful scene depth to test against. 2D_Default.fofx and 2D_WithoutEgg.fofx discard fragments whose final alpha is at or below 1/255 (after egg alpha), so fully transparent sprite texels do not populate the depth buffer and clip in-scene particles behind the empty parts of the atlas quad.
  • Within one standing screen row, MapSpriteList::MakeDrawOrderPos sorts by [group 8][primary row 24][layer 8][sub-layer 8][hex X 16]. Layer precedes sub-layer and X: critters paint in front of every upright item in their screen row; within the item layer, lower-sub-layer walls paint first. Nearer rows still win, and insertion order breaks an otherwise equal key. Item.DrawOrderSubLayer is mutable and publicly synchronized; a client property update refreshes the item’s map sprites so their sort keys change without reloading the map.
  • A flattened dead critter stays in the lower draw-order group than upright items and critters even when their screen row differs. Same-row door flaps and frames still follow sub-layer before X within the item layer. Test_MapSprite.cpp pins both cases; visual acceptance must still check the actual scene.
  • Roof tiles (IsRoofTile) are ordinary floor tiles given a fixed positive Elevation (Geometry.MapRoofElevation): the projection raises their screen position onto the building’s wall tops (the engine still auto-hides the roof group whose RoofNum the camera is inside). A roof is just a tile lifted in screen Y — the flat tile/roof sub-hex XY anchor now lives on the BaseTile prototype’s Offset, not on the former per-side Geometry.MapTileOffs*/MapRoofOffs* settings (removed); the roof-particle and mapper tile-preview paths read the same Elevation instead of the old 2D roof offset. The roof draw-order range (Roof..Last) is rendered as a separate trailing pass (MapView::DrawSpritesWithFog splits at below_roof = Roof-1 and draws [Roof..Last] last, via DrawFoggedSpriteRange): everything below it — including the direct-draw 3D models / in-scene particles each sprite pass replays at its end — is drawn first. Like floor tiles, roofs do not touch the depth buffer (Effects.Roof → 2D_NoDepth.fofx, DepthWrite = False + DepthFunc = Always): being drawn last and never depth-tested, the roof layer always paints on top of the building regardless of the scene depth buffer, and being depth-write-free it never clips anything drawn after it.
  • Particle effects (Particles_*.fofx) declare DepthFunc = LessEqual + DepthWrite = False: tested against scene depth so they are occluded by closer geometry, without occluding each other. The colour variants (Particles_Color*.fofx) additionally declare DepthVariants = True, because a particle runtime whose format stores depth-test/depth-write per emitter node selects the matching variant per draw. All colour and distortion *Atlas fragments discard final alpha at or below 1/255, so a node that opts into depth writing cannot turn transparent or filter-fringe atlas texels into invisible occluders. They also read ParticleSamplingBuf, whose .x snaps the sampled coordinate to the texel centre: filtering is a per-atlas property here (Render.AtlasLinearFiltration applies to every atlas) but a per-node one in particle formats, and a bilinear fetch at a texel centre returns exactly that texel, so a node can get true point sampling from a linearly filtered atlas without a second atlas. The *Atlas variants additionally read SpriteBorderBuf and address the whole coordinate inside that atlas sub-rectangle, tiling or clamping as ParticleSamplingBuf.y says: neither a hardware wrap mode nor hardware clamping can serve a shared atlas, because both would reach into neighbouring entries. A runtime whose format supplies raw per-node coordinates therefore draws through the *Atlas variants exclusively; the non-atlas variants are for SPARK, which supplies final atlas coordinates and explicitly binds the neutral sampling buffer before every draw.
  • Model effects (3D_*.fofx) use DepthFunc = LessEqual + DepthWrite = True: direct-to-scene models write real mesh depth into _rtMap, so particles and later direct geometry can test against the model surface instead of the old model atlas quad.
  • OnRenderMap_AfterSpritesAndFog fires after the sprite/fog map pass and before the map target is flushed. Scripts that need entity debug markers should iterate the relevant visible Item/Critter objects, combine Map.GetHexMapPos(entity.Hex), entity.GetSpriteOffset(), and entity.Elevation, then subtract the event draw-area origin; this keeps selection/filtering in scripts without depending on transient sprite instances.
  • Entity contours/outlines are script-driven, not native. There is no engine contour pass; an embedding-project script (Last Frontier: ContourPipeline.fos, compiled for CLIENT and MAPPER) keeps a cache of entities whose Contour colour property is non-zero and, on OnRenderMap_AfterSprites, draws each via Map.DrawEntitySprite(entity, contourEffectSubtype, colour, padding) (a dilated silhouette in the contour effect, then the sprite on top so only the rim shows). The mapper’s selection outline uses the same path — it sets the selected entity’s Contour property (the property is registered for the mapper because Client entity registration includes the mapper target) rather than any native call.

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):

  • Atlas type (default, draw in scene absent/false): Update() advances simulation independently, then refreshes the offscreen atlas (ParticleSpriteFactory::DrawParticleToAtlas) at the configured animation cadence; the sprite is drawn as a flat batched quad. IsDirectDraw()==false.
  • Scene type (draw in scene = true): IsDirectDraw()==true; Update() advances simulation even when the sprite is not visible, and DrawInScene refreshes the current scene transform without advancing frame time before rendering directly into _rtMap through the map view-proj. Particles therefore keep their lifetime offscreen and depth-sort against scene geometry instead of being baked to a flat sprite.

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.

Live render bounds and rebasing of already emitted particles are backend capabilities behind the same facade. Both backends report live bounds from their baked extent (SPARK through the .spk bounds attribute, Effekseer through the .efk bounds trailer). Rebasing of already emitted particles is SPARK-only, because it emits in world space; Effekseer composes instance transforms with the effect root matrix, so it treats rebasing as a no-op and needs none.

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 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 (BeginRenderingGroup for the strip families, whose geometry is per instance group rather than per node); subsequent Rendering calls cannot append more instances than that declaration, and a strip additionally requires its instances to arrive in chain order, because the band is stitched by instance index. 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. Both collectors Z-sort a lightweight index permutation and then materialize the reordered instances, rather than sorting the instance-snapshot vectors in place: the snapshots embed alignas(16) Effekseer SIMD members, and std::stable_sort on an over-aligned element type instantiates std::aligned_storage with an extended alignment that the standard library rejects. The stable order over the permutation keeps the draw order deterministic.

Distortion nodes refract the scene instead of drawing their own colour: the red and green channels of their texture are a displacement in the particle’s own plane, and the fragment samples the background snapshot at the displaced position, keeping the texture’s alpha so the particle’s shape still masks the refraction. Reproducing that faithfully needs the plane per vertex — a rotated or freely oriented particle has its own, and deriving it from screen axes would rotate the refraction wrongly — so these draws use the model vertex layout, which already carries a tangent and a bitangent, rather than widening the 2D vertex. The displacement intensity and the background’s orientation travel in the reserved ParticleSamplingBuf channels. Only the sprite family refracts; the other families reject the material when the effect loads, so an effect that cannot be drawn is never accepted and then seen to vanish.

Model nodes draw a mesh per instance instead of a generated shape. The .efkmodel payload is loaded through an Effekseer::ModelLoader implementation, but the vendored parser trusts its byte counts and face indices, so the engine first validates the complete payload and enforces structural budgets (64 MiB, 4096 frames, 64000 vertices and 21333 faces per frame). Only then does the core parse it. The renderer folds each instance’s transform into the mesh vertices, exactly as the other families bake their geometry into world space. That keeps model particles on the same frontend draw-buffer path, atlas addressing and batching as every other particle draw; the node’s culling mode travels as the draw’s CullMode, and an animated mesh selects its frame by the instance time modulo the frame count.

Ribbon and Track (strips) share one geometry builder, because they differ only in how a single cross-section of the band is produced: the builder turns a chain of per-instance width triples (left edge, centre, right edge, with the colours at them) into two quad strips meeting on the centre line, stretches the texture along the whole chain, resolves the colour texture, publishes the atlas addressing flags, and chunks against the same vertex budget the Ring path uses. A Ribbon transforms the two authored edge offsets by the instance matrix, or - when the node is viewpoint dependent - spreads them around the emitter’s own up axis so the band keeps facing the camera. A Track centres each cross-section on its instance, spreads it across the direction of travel (averaged at interior joints so the band does not kink), and interpolates width and colour from the head and tail values toward the middle ones. Sub-parameters the builder does not implement - spline smoothing, tiled strip UVs, trail smoothing, view offset, left-handed strips, non-Default track materials, and Z-sorting, which would reorder the instances the chain is stitched from - are rejected before a vertex is built. Note that a ribbon’s Positions[2]/[3] are read only by the spline path and arrive uninitialised, so nothing may validate them.

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 strip fixtures are compiled from project source inside the test instead of cooked, because a strip only becomes geometry once several instances of one group are alive together; they pin the two-quads-per-segment structure, the shared centre line, chain continuity, the stretched UV assignment, the band width, and the viewpoint-dependent orientation.

The initial callback adapter accepts one Default-material color texture. Any node may omit it and then draw against a renderer-owned white pixel so their vertex colors still match Effekseer. Clamp and Repeat wrapping are both accepted and resolved in the shader against the published atlas sub-rectangle, for any UV value; only Mirror is rejected. A node’s requested Linear/Nearest mode need not match the atlas: Nearest from a linearly filtered atlas is served by snapping to the texel centre per draw. 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 and Effekseer 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; Effekseer distortion attachments additionally pull the direct replay’s scene-background snapshot on demand. Direct scene draws still disable the old model shadow pass because its shader math is atlas-space and needs a separate world-space rewrite.

Cached model-sprite frames use the resolved logical cap: the minimum of Render.ModelSpriteMaxTextureWidth / Height and the current machine’s AppRender::MAX_ATLAS_WIDTH / HEIGHT, divided by FRAME_SCALE because the model renders at 2x into the physical scratch texture. Dynamic model-bone particle bounds above this budget are treated as unavailable: the established model frame remains valid and only runaway outlying geometry is clipped. This prevents malformed or long-lived particle motion from requesting unbounded CPU/GPU allocations in headless and rendered paths.

World scale. Render.ModelProjFactor is the screen px per 3D world unit and is shared by 3D models and in-scene particles. The Engine default is 40.0; an embedding project may override it consistently for both systems. 1 world unit = 1 hex = 1 m remains the authoring metric, but the pixel projection factor does not have to equal MAP_HEX_WIDTH. A scene-type system that emits within a radius of N units therefore spans N hexes on the ground, matching direct-to-scene 3D models authored to the same scale. See Particle Format And Runtime for the complete backend-neutral particle route.

Embedding-project practices

Current embedding projects demonstrate useful composition patterns, but their specific settings, effect names, scripts, and acceptance evidence remain project-owned:

  1. Keep every Render.Force* selector false in the ordinary shipping profile. Add narrow backend sub-configs or launch recipes for diagnosis and cross-backend acceptance; set exactly one force selector in each recipe.
  2. Do not copy an older project’s render block into a current project. Start from the settings generated for the pinned Engine revision, then review each override. Backend selectors and model/layout settings have changed over time, so an old .fomain is migration evidence, not a template.
  3. Put richer, project-owned shaders in a resource pack ordered after the Engine Core/Embedded packs. Shadow the same resource name only when the project intentionally replaces a minimal base effect, and preserve the slot’s alpha, depth, descriptor, and uniform contract. These are advanced-profile overrides, not changes to the Engine fallback.
  4. Treat Render.ModelProjFactor as one project-wide authoring decision shared by models and in-scene particles. Changing it is a visible content-scale migration; validate representative creatures, attachments, effects, hit areas, and map occlusion together.
  5. Keep logical resolution policy, available settings choices, project effect slots, offscreen composition, contours, and accessibility/visual acceptance in project docs and tests. The Engine supplies the mechanics but cannot certify a game’s UI or art direction.
  6. Qualify every renderer/driver that a release claims. A clean default-backend scene does not prove Vulkan, SDL_GPU, WebGL, or a specific SDL_GPU driver; record the exact selector, driver, platform, scene, logs, and screenshot or interaction evidence.

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:

  • frontend source defines what the engine can do;
  • CMake/BuildTools decide which apps/backends/platform packages are built;
  • embedding-project presets choose concrete configurations;
  • platform docs explain how to debug the resulting package.

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 NullRenderer case covers texture read/write/clear, draw-buffer upload and effect draw, and rejection of an unbuilt depth variant. Atlas packing and dump geometry live in Test_TextureAtlas.cpp; matrix/depth projection lives in Test_Geometry.cpp; model, image, and particle suites own their respective runtime paths.

The NullRenderer requested-region sections check row layout, the request-time snapshot, bounds, and exactly-once transfer. Test_ClientEngine.cpp contains ModelSpriteHitTestReadsItsMaskFromTheAtlas, covering CPU-mask reuse and redraw refresh. That fixture is compiled only with FO_ANGELSCRIPT_SCRIPTING; a Managed-only build does not execute it. Neither fixture validates fence timing or visible picking on a GPU backend.

Native tests prove backend-neutral invariants, not a GPU implementation. Pair them with a visible target-specific route for every affected implementation: Null/headless, OpenGL/WebGL, Direct3D, Vulkan, and SDL_GPU. Direct Metal has no implemented route and cannot produce acceptance evidence.

Validation checklist

When changing frontend or rendering behavior, verify:

  • Application init still works for graphical, headless, and test/tool flows.
  • Input changes preserve InputEvent invariants and client script event mapping.
  • Touch/gamepad changes are platform-neutral unless clearly guarded.
  • Renderer changes are tested on the affected backend: Null/headless, OpenGL/WebGL, Direct3D, Vulkan, or SDL_GPU. Do not count the direct Metal placeholder as implemented coverage.
  • Render-target changes preserve stack push/pop behavior and previous-target restoration.
  • Texture orientation changes account for IsRenderTargetFlipped() differences between OpenGL (flipped) and Direct3D/Vulkan/SDL_GPU (not flipped).
  • Effect changes document config parsing, shader files, and script-value buffer implications. On Vulkan, confirm shader resources follow the set-0-UBO / set-1-sampler descriptor-set contract.
  • Web changes cross-link to Web Build, Packaging, and Browser Debugging; Android changes cross-link to Android Build, Packaging, and Device Debugging; native attach/debug changes cross-link to Native, AngelScript, and Managed Debugging.
Start typing to search.