Architecture¶
Context and goals¶
vfxweaver is a client-side VFX library mod for Minecraft on Fabric and NeoForge (six jars, one per Minecraft line and loader): the server triggers effects over the network (or another mod — directly via VFXAPI on the client), the client plays and renders them. Goals: (1) declarative effects via datapack JSON without recompiling, (2) bounded render load even with many concurrent effects, (3) fault tolerance — one broken effect/datapack file must not break the rest.
The systems below are loader-agnostic: they touch only Minecraft/Mojang APIs, never net.fabricmc.* or net.neoforged.*. The loader is reached solely through the platform layer, so the same source builds every Fabric and NeoForge jar.
Core systems¶
┌──────────────────────┐
datapack JSON ─────▶│ VFXDefinitionManager │ (common: main + client-as-single-player)
└──────────┬───────────┘
│ VFXDefinition (type-safe model)
▼
/vfx play, VFXAPI ──▶ VFXEffectManager (client) ──▶ VFXActiveEffect (timeline + positions + fade)
│
┌─────────────────┼─────────────────────┬──────────────────┐
▼ ▼ ▼ ▼
VFXPostProcessingManager VFXWorldOverlayRenderer CameraShakeManager VFXEntityEffectRenderer
(shader post-effects) (block_tint/outline) (camera shake, (entity_tint/outline,
FOV) second model pass)
│
▼
FlashbackCompat (client, optional) ── writes replay actions into Flashback.RECORDER
VFXServerEffects (server) ────────── re-applies remembered effects to (re)joining players
VFXDefinitionManager(main) — definition registry: built-ins (registerBuiltIns()) + datapack (data/<ns>/vfx/<name>.json, reloaded viaSimplePreparableReloadListener). Registered on both the server and the client (for single-player).VFXEffectManager(client, singleton) — the single source of truth about what is currently playing: theactivelist (List<VFXActiveEffect>) andscheduled(deferred collection children), a shared effectclocktimer in ticks.VFXActiveEffect— one playing instance:VFXTimeline(animated params + world bindings) + fade-in/out weight + a list of positions (for world overlays) + a list of target UUIDs (for entity effects).VFXPostProcessingManager(client) — runs active post-effects through ping-pongTextureTargets every frame.VFXWorldOverlayRenderer(client) — drawsblock_tint/block_outlineover block geometry viaLevelRenderEvents.AFTER_TRANSLUCENT_TERRAIN.VFXEntityEffectRenderer(client) — registers custom pipelines/render types forentity_tint/entity_outline; the actual drawing is done by theLivingEntityRendererMixinin a second model pass.CameraShakeManager/CameraMixin(client) — sums the noise of all activecamera_shakeeffects into a position/rotation offset, applied by a mixin toCamera.VFXWorldBindings(main, but data lives on the client only) — computesbindparams (screen_x,proximity,look,distance,look_x/y/z,player_x/y/z,camera_yaw_delta/pitch_deltaand player state:health/hunger/speed/light_level/time_of_day) relative to the current camera frame and the player snapshot.
Loader platform layer¶
The core systems above are loader-agnostic — they never import net.fabricmc.* or net.neoforged.*; the loader is reached only through the platform packages:
VFXPlatform(main) — loader name and mod-loaded queries (isModLoaded,name).VFXNetwork(main) — payload registration and transport (registerCommon,sendToPlayer,allPlayers) plus the client-receiver dispatch table (registerClientReceive/dispatchClient).VFXLoaderEvents(main) — server lifecycle, tick, command registration, datapack reload and player-join wiring (including the definition/curve sync).VFXClientNetwork(client) — registers the client-bound receivers on the loader's client networking API and forwards them toVFXNetwork.dispatchClient.VFXClientRenderHooks(client) — client lifecycle/tick/join events and the world-overlay render-event plumbing, capturing the camera and geometry sink soVFXWorldOverlayRendererstays loader-agnostic.
The entry points are guarded per loader: VFXMod (Fabric ModInitializer) and VFXNeoForgeMod (@Mod) on the common side, and VFXClient (ClientModInitializer / @Mod(dist = Dist.CLIENT)) on the client. Client-only safety: all client code stays in src/client and src/main never references it, so the dedicated server never loads a client class.
Data flow per frame¶
GameRendererMixin.vfxweaver$render(injection beforeFogRenderer.endFrame) — called once per frame:- updates
VFXWorldBindingsfrom the current camera (position, yaw/pitch, view-rotation-projection matrix); - advances
VFXEffectManager.clockbydeltaTicks(DeltaTracker.getGameTimeDeltaTicks(), 0 on pause); VFXEffectManager.update()— removes finished effects, fires due collection children, advances timelines;VFXPostProcessingManager.process(...)— runs the chain of shader passes.CameraMixin(injections inCamera.calculateFov/update) — reads the already-updatedVFXEffectManagerfor the FOV delta and camera shake.LevelRenderEvents.AFTER_TRANSLUCENT_TERRAIN—VFXWorldOverlayRendererdraws world overlays for activeblock_tint/block_outline.- In entity rendering,
LivingEntityRendererMixin(injection right after the vanillasubmitModelinsubmit) reads the entity's UUID from the render state (ITomVFXEntityState, filled inextractRenderState) for every living entity and, if there is an activeentity_tint/entity_outlinefor that UUID, callssubmitNodeCollector.submitModelagain with a custom render type — a second pass over the original model in the same transform space.
Post-processing (pipeline)¶
The hook is right before FogRenderer.endFrame() in GameRenderer.render, i.e. after the world and the vanilla post chain, but before the GUI. Each active post-effect expands into one or more shader passes (VFXShaderPrograms.getPrograms(type), e.g. blur = X+Y). A copy of mainTarget → pingPong[0], then the pass chain alternates pingPong[0]/pingPong[1], the last pass writes back into mainTarget. Each pass is an ortho projection + a SamplerInfo UBO (in/out sizes) + an optional Config UBO (effect params, blended with the neutral value by the current fade weight — VFXEffectType.neutralValue), both via MappableRingBuffer (mapped and rotated every frame).
Per-pixel fields¶
A field-capable input (currently dent.intensity and color_grade.tint_r) may carry a { "field": ... } object that varies per pixel. The model lives in the MC-free dev.vfxweaver.field package (shared src/main, no net.minecraft.*/com.mojang.*): VFXField parses and validates the tree (type coercion, depth/leaf/node/texture caps, graph references on numeric parameters), and VFXFieldProgram flattens it once per instance into the fixed uniform program (leaf metadata, a MAX_PARAMS-wide parameter vector per leaf, a curve pool and a post-order instruction list). VFXDefinition stores the fields and VFXTimeline packs them, exposing getFieldProgram, fieldNeedsDepth and the per-frame graph evaluator. At runtime VFXPostProcessingManager writes a FieldConfig UBO each frame from the packed program plus a preallocated inverse view-projection (VFXFieldEnv), binds the main target's depth as DepthSampler (NEAREST) and the optional field texture as fld_tex0, and assets/vfxweaver/shaders/include/field.glsl evaluates the built-in functions and the composition program.
- UBO field-order rule.
field.glsl'sFieldConfigdeclaration order,VFXFieldProgram.write's emission order andVFXShaderPrograms.FIELD_CONFIG_SIZEare one positional std140 contract — change all three together. The four leading floats arefld_uniform,fld_depth_valid,fld_leaf_count,fld_weight;fld_weightreuses the padding the three-float form left beforefld_leaf_fn, so the later offsets are unchanged. - Fade.
vfx_field_intensityblends the field against its neutral1.0byfld_weight, so a field-driven input reaches exactly the neutral value at weight 0 (dent_field_demo/tint_field_demono longer needfade_ticks: 0for correctness). - Shared shapes.
field.glslowns the 2Dcircle/ellipse/rect/polygon(withfill,stroke_width,softness,repeat) and the 3Dsphere/boxhelpers; masks andsurface_patternconsumevfx_shape_sdf/vfx_shape_coverageand never re-implement them. - Layer 0. Depth/world fields reconstruct a world position from the scene depth, which is only valid at screen layer 0; elsewhere the Java side marks the depth invalid and the shader returns the neutral value (a once-per-definition warning is logged for fields that need depth). Screen-space fields work at every layer.
World overlays¶
block_tint/block_outline are drawn not as a shader pass but as geometry: the block model's baked quads (ModelManager.getBlockStateModelSet(), fallback a full cube), transformed in a PoseStack relative to the camera. block_outline supports two modes (the shell param): 0 — walls (each face is extruded outwards along its normal, physically cannot cover the block), 1 — a classic scaled shell with back faces + back-face culling, clipped by the block's own depth buffer.
Entity effects (second model pass)¶
entity_tint/entity_outline are also geometry, but of the entity model rather than the world: LivingEntityRendererMixin in submit calls submitNodeCollector.submitModel again with the same model/state/poseStack but a different RenderType. Vanilla EntityRenderState has no UUID field — a mixin on LivingEntityRenderState adds one (the ITomVFXEntityState interface), filled in extractRenderState. Both render types use DefaultVertexFormat.ENTITY (model vertices; the shader ignores textures/overlay/lightmap) with custom pipelines (assets/vfxweaver/shaders/core/entity_fx.{vsh,fsh}) over MATRICES_FOG_LIGHT_DIR_SNIPPET — the standard UBOs (Projection/DynamicTransforms/Fog/Globals) are bound the standard way, no separate UBOs needed.
entity_tint: a fill of the model; the effect ARGB is passed astintedColortosubmitModeland becomes the vertex color. Two modes selected by the booleantexture:1— recolour the texture (texture rgb × effect color, keeps the texture alpha),0— flat color with the texture only as an alpha mask (like vanillarendertype_outline). DepthLEQUAL(occluded) orALWAYS_PASS(through_blocks: 1),TRANSLUCENTblending — lands in theModelFeatureRenderertranslucent bucket and draws after opaque entity bodies.entity_outline: an inverted hull — the model is scaled by1 + widtharound its vertical centre (boundingBoxHeight/2), the fragment shader discards front faces (gl_FrontFacing), depthLEQUALleaves only the rim behind the silhouette (orALWAYS_PASSfor through-wall glow). Width is set by scale, not a uniform: thesubmitModelpath has no way to bind a custom UBO for a per-draw value, and the pipeline API has no front-cull.
Both effects bind the entity texture as Sampler0 and use it as an alpha mask: texels with zero alpha are discarded, so the effect follows the texture silhouette rather than a flat box around the model. Render types are memoized by the texture Identifier (LivingEntityRenderer.getTextureLocation(state), passed from the mixin); pipelines are shared per (mode, through-blocks).
Targets are set by UUID: /vfx playentity <effect> <selector> collects up to 16 UUIDs and sends them in vfxweaver:vfx_trigger (entityUuids); VFXEffectManager.getActiveEntityEffects(uuid) finds the active effects for a specific entity. The UUID cap is VFXTriggerPayload.MAX_ENTITY_UUIDS.
Flashback integration¶
Flashback (https://modrinth.com/mod/flashback) is a soft dependency: the mod works without it, and nothing in the code compiles against it — all access is reflective (Class.forName, Proxy), guarded by VFXPlatform.isModLoaded("flashback"). It is Fabric-only (Flashback has no NeoForge build), so on NeoForge the guard is false and the recording layer is skipped.
-
FlashbackCompat(client) — registered as anAction(vfxweaver:effect_trigger) in Flashback'sActionRegistry. Client-local plays (VFXAPI.playEffectthroughVFXClientAPI) are written into the active replay viaRecorder.submitCustomTask(effectId + durationTicks + easing + params); on playback Flashback calls the action'shandle, which decodes the payload and re-triggers the effect on the render thread. A per-tickEND_CLIENT_TICKhook detects a recording start (Flashback.RECORDERbecoming non-null and ready) and snapshots the already-running effects so they appear from the first replay tick. Persistent/looping effects are skipped (no recorded stop event → they would loop forever). Server-triggered effects are not recorded here — they travel asvfxweaver:vfx_triggerpackets which Flashback captures and replays itself. -
VFXServerEffects(server) — remembers everyVFXAPI.sendEffectper player (player → effectId → params/duration/easing/startTick). On player (re)join (SYNC_DATA_PACK_CONTENTS, after datapack sync) the still-active effects are re-sent with their remaining duration; expired entries are pruned, persistent (-1) always re-applied. Bounded per player (MAX_EFFECTS_PER_PLAYER). Disabled while a Flashback replay is being played back (Flashback.isInReplay()) so effects already carried by the replay are not doubled.
Load limits (protection against effect spam)¶
| Constant | Value | Where |
|---|---|---|
MAX_ACTIVE_EFFECTS |
64 | VFXEffectManager — on overflow the oldest active is removed, with a warning in the log |
MAX_SCHEDULED_EFFECTS |
128 | VFXEffectManager — extra collection children are dropped |
MAX_COLLECTION_DEPTH |
4 | VFXEffectManager — deeper nested collections are ignored |
Any new collection/map that grows from network or datapack input must get a similar limit.
Fault tolerance¶
VFXDefinitionManager.prepare()— one broken datapack entry is logged and skipped, the rest load normally (see the Changelog).VFXWorldOverlayRenderer.render()— each effect's render is wrapped in try/catch with a log; an error in one effect does not block the rest or drop the frame.VFXClient.handleTrigger— a packet with a mismatchedprotocolVersionis silently ignored instead of crashing.
See also: API.md — the public Java API and network protocol, guide/ — the user guide.