Runtime Procedural VFX
Runtime Procedural VFX (no asset bundle)
Part of the 7DTD Modding Knowledgebase. Covers VFX built at
runtime in C# from raw Unity primitives (LineRenderer, ParticleSystem, Light)
plus procedurally-generated textures/materials — i.e. effects that ship no
.unity3d bundle. For bundle-based assets see Asset Bundles.
The big gotcha: HideAndDontSave on procedural textures & materials
A procedural Texture2D/Material you build at runtime and hold in a static
field will be destroyed out from under you by the engine. 7DTD calls
Resources.UnloadUnusedAssets() on world transitions, save load, chunk
streaming, etc. That pass destroys any asset Unity thinks is unreferenced — and
a plain C# static reference does not count as a reference it respects.
Symptom: the first spawn of an effect looks right; later spawns render with a fallback texture — most commonly the most-recently-bound UI font atlas, so "all my projectiles/particles suddenly look like glyphs." The C# wrapper is still non-null, but its underlying GPU texture/shader was freed.
Fix: set hideFlags = HideFlags.HideAndDontSave on the cached texture/material
so the cleanup pass skips it.
var tex = new Texture2D(64, 64, TextureFormat.RGBA32, false);
tex.hideFlags = HideFlags.HideAndDontSave; // survives UnloadUnusedAssets
// ... fill pixels, tex.Apply();
var baseMat = new Material(mat);
baseMat.hideFlags = HideFlags.HideAndDontSave;
Also guard the cache against partial-destroy states — check the shader too, not
just the material, before reusing: if (cached != null && cached.shader != null).
HideAndDontSave does NOT save a shared material from GameObjectPool
A second, unrelated destroyer — and HideAndDontSave is no defence, because
this one calls Object.Destroy explicitly rather than relying on the unused-
asset sweep. It bites whenever a runtime material is reachable from a
pooled GameObject (any block model entity, prop, or entity model).
GameObjectPool.DestroyObject sweeps before destroying:
void DestroyObject(GameObject obj) {
obj.GetComponentsInChildren(tempRenderers);
Utils.CleanupMaterialsOfRenderers(tempRenderers); // <-- destroys materials
UnityEngine.Object.Destroy(obj);
}
// Utils.CleanupMaterials: destroys every material where GetInstanceID() < 0
A negative GetInstanceID() is Unity's marker for "created at runtime"
(assets loaded from a file get positive IDs). So the heuristic is "any runtime
material under a pooled object is a per-instance clone that object owns, so
free it" — correct for the copies Unity auto-makes when something touches
Renderer.material, and catastrophically wrong for a static material you
share across instances. Every new Material(...) is negative-ID; there is
no way to opt out, and hideFlags is not consulted.
(Utils.MarkMaterialAsSafeForManualCleanup only appends " (Instance)" to the
name — it is not read by this path. Don't expect it to help.)
Symptom: destroying one instance turns every other instance magenta —
they are all rendering on the same now-destroyed material. Anything that latches
on mat != null (an EnsureArt-style init guard) then wedges permanently
false, so the next object built comes up blank/white instead of magenta. Two
different-looking symptoms, one cause. Timing is effectively immediate: a fresh
PoolItem has updateTime = 0, so the next FrameUpdate shrinks the pool and
destroys the clone a frame or two after the block is removed — no 10s wait.
Fix (Elevator src/ElevatorPanelModel.cs, Patch_GameObjectPool_DestroyObject):
exempt your own model by name and destroy it without the sweep.
[HarmonyPatch(typeof(GameObjectPool), "DestroyObject")]
public static class Patch_GameObjectPool_DestroyObject {
public static bool Prefix(GameObject obj) {
if (obj == null || obj.name != MyModel.ModelName) return true;
Object.Destroy(obj); // shared art set outlives every clone by design
return false;
}
}
Matching on obj.name is exact, not a guess: the pool is keyed by modelName
(BlockShapeModelEntity.Init → AddPooledObject(modelName, …)),
PoolItem.Instantiate stamps gameObject.name = name on every clone, and
PoolObject only accepts an object whose name resolves to a PoolItem.
Safe because a clone shares its meshes and materials with the prefab
(Instantiate copies references, not assets) — there is nothing instance-owned
to free. The alternative (give every clone its own material copies, complying
with the engine's assumption) costs batching and needs a hook on all three
clone paths (pool, placement preview, mover proxy) — prefer the exemption.
GameObjectPool.DestroyObject is the only call site that can reach a
block's renderers; the other Utils.CleanupMaterials* callers are
CharacterConstructUIController, GearBoneMap, SelectionBox,
TemporaryObject and VoxelMeshTerrain. So plain Object.Destroy paths (the
held-item preview, a mover's proxy clone) are safe and need no patch.
Particle material from a known additive shader (don't borrow scene materials)
To get an additive particle material, build from a named shader rather than copying whatever material happened to be loaded (a borrowed scene torch/fire material can have its texture unloaded later, giving the ghost-texture bug above). Probe a fallback chain and clone:
string[] shaderNames = {
"Legacy Shaders/Particles/Additive", "Particles/Additive",
"Mobile/Particles/Additive", "Particles/Standard Unlit",
"Unlit/Transparent", "Sprites/Default" };
Material mat = null;
foreach (var n in shaderNames) { var s = Shader.Find(n); if (s != null) { mat = new Material(s); break; } }
mat.mainTexture = softCircleTexture;
mat.color = color;
if (mat.HasProperty("_TintColor")) mat.SetColor("_TintColor", color);
A 64×64 soft-circle (radial alpha falloff, squared) makes particles read as fuzzy round glows instead of hard squares.
Procedural lightning (LineRenderer)
A convincing bolt = fractal midpoint displacement for the trunk path
(subdivide each segment, perturb the midpoint perpendicular to the axis, halve
the amplitude each generation) + a few deflected sub-branches + per-frame
visibility flicker + a short lifetime (~0.4s) so it strobes like a real
strike rather than a steady beam. Scale the displacement to bolt length
(max(floor, length * ratio)) or long bolts look near-straight. Add a point
Light at the impact end, faded with the bolt, for the flash. LineRenderer
Lightonly — both inUnityEngine.CoreModule(no ParticleSystem module reference needed). Reference implementation: zPhonesrc/apps/God/GodLightning.cs(GodLightningBolt), ported from the Scepters mod.
Beam LineRenderer flares wide where it meets a surface (depth test)
A LineRenderer "laser" drawn from a muzzle to a raycast hit point, using a
depth-tested shader (Sprites/Default, or any default sprite/unlit shader),
flares into a wide blob at its endpoint whenever the beam runs nearly parallel
to — or grazes at a shallow angle — the surface it lands on. The thin end-quad
sits coplanar with the block face and z-fights / partially clips against it, so
the flat ribbon smears across the surface. Symptom report: "the laser gets wide
on certain blocks" — i.e. only at certain look angles, not a constant width.
Fix: draw the beam with depth testing off so it never interacts with the
surface — it renders as a clean constant-width line on top. The muzzle→crosshair
path is always clear line-of-sight (it is the targeting ray), so drawing over
the world is correct. Sprites/Default has no settable ZTest, so switch to
Hidden/Internal-Colored, which exposes _ZTest/_ZWrite/_SrcBlend/
_DstBlend/_Cull as material ints and honours the LineRenderer's per-vertex
colours:
Shader s = Shader.Find("Hidden/Internal-Colored"); // always present in builds
Material mat = new Material(s) { color = Color.white };
mat.SetInt("_ZTest", (int)UnityEngine.Rendering.CompareFunction.Always);
mat.SetInt("_ZWrite", 0);
mat.SetInt("_SrcBlend", (int)UnityEngine.Rendering.BlendMode.SrcAlpha);
mat.SetInt("_DstBlend", (int)UnityEngine.Rendering.BlendMode.OneMinusSrcAlpha);
mat.SetInt("_Cull", (int)UnityEngine.Rendering.CullMode.Off);
mat.renderQueue = 4000; // after transparent geometry
Keep a Sprites/Default fallback if Shader.Find returns null. Reference:
Airstrike src/ItemActionAirstrikeDesignator.cs (CreateLaserMaterial).
Beam LineRenderer balloons wide when aimed toward/away from the camera (alignment)
Separate from the depth-test flare above. A LineRenderer left on its default
LineAlignment.View (camera-facing billboard) balloons into a wide flat ribbon
whenever a segment runs nearly parallel to the camera's view ray — the
camera-facing quad becomes ill-conditioned and the width compensation
overshoots. Symptom: "the laser stretches/gets wide at certain angles" even
though the configured width is millimetres. This persists after the depth-test
fix because it's a geometry/alignment issue, not a surface interaction.
Fix: switch to LineAlignment.TransformZ so width is laid in a stable plane
whose normal is transform.forward, then each frame aim that forward at the
camera while keeping it perpendicular to the beam. The naive
LookRotation(-cam.forward, cam.up) does NOT work: when you aim down the beam's
length (e.g. looking down at a near-ground target) the beam runs (anti)parallel
to cam.forward, so the normal lines up with the beam axis, the width cross
product cross(beamDir, normal) collapses to zero, and it balloons again — same
bug, worst spot. Instead project the beam direction out of the camera→start
vector and use the leftover in-plane component, which is perpendicular to the
beam by construction:
Vector3 beamDir = (targetWorld - startWorld).normalized;
Vector3 toCam = camWorld - startWorld;
Vector3 normal = toCam - Vector3.Dot(toCam, beamDir) * beamDir; // strip beam axis
if (normal.sqrMagnitude < 1e-6f) normal = cam.up; // aimed at the eye
transform.rotation = Quaternion.LookRotation(normal.normalized, beamDir);
The beam then renders a constant-width line across all look angles and only
thins (realistically) when aimed straight at the viewer — a flat ribbon
containing a view-parallel beam cannot also face the viewer broadside, so
thinning is the correct result, not ballooning. Reference: Airstrike
src/ItemActionAirstrikeDesignator.cs (EnsureLaser sets the alignment;
AirstrikeLaserView.LateUpdate builds the perpendicular normal).
⚠️ TransformZ does NOT actually work in 7DTD's engine — use a quad mesh
The LineAlignment.TransformZ fix above is the documented Unity behaviour, but
in 7DTD's build (Unity 2022) the LineRenderer ignores it and billboards anyway.
Verified at runtime: LineRenderer.alignment read back as Local (the old name
for the TransformZ enum value, confirming it was set), Shader.Find("Hidden/ Internal-Colored") returned non-null (depth-off material fine), the beam ran
beamVsViewDeg ≈ 0.2° with widths of 3–9 mm — and it still exploded the far
vertex into a screen-filling wedge. So setting alignment = TransformZ + rotating
transform.forward at the camera has no effect; the engine billboards the line
as if it were View. There is no renderer-level knob that fixes it.
Robust fix: stop using LineRenderer entirely and build the beam as a hand-made
two-triangle quad (MeshFilter + MeshRenderer + a dynamic Mesh), computing
the width direction yourself so there's no billboard to degenerate. Keep the GameObject
at identity transform and write vertices in render-space world coords (world - Origin.position). Per frame, with the beam clamped in front of the near plane:
Vector3 beamDir = (targetRender - startRender).normalized;
Vector3 camPos = viewCam.transform.position; // render space
// Width ⟂ beam AND ⟂ eye-ray → ribbon faces the camera, width lies across screen.
Vector3 wsDir = Vector3.Cross(beamDir, camPos - startRender);
if (wsDir.sqrMagnitude < 1e-8f) wsDir = viewCam.transform.right; // sighting down beam
Vector3 weDir = Vector3.Cross(beamDir, camPos - targetRender);
if (weDir.sqrMagnitude < 1e-8f) weDir = viewCam.transform.right;
Vector3 ws = wsDir.normalized * halfWidthStart; // half-widths (offset each way)
Vector3 we = weDir.normalized * halfWidthEnd;
verts[0]=startRender+ws; verts[1]=startRender-ws; verts[2]=targetRender+we; verts[3]=targetRender-we;
// tris {0,2,1, 2,3,1}; per-vertex colours (Internal-Colored passes vertex colour
// through ×_Color=white); mesh.RecalculateBounds() each frame or it frustum-culls.
The cross-product width is perpendicular to the beam by construction (no
projection/degeneracy maths needed) and the quad turns its face to the camera, so
it's a clean constant-width ribbon at every angle and thins to a sliver only when
sighting straight down the beam (correct). Note the width is world-space (tapers
with distance, like the old LineRenderer width did) — scale each half-width by
distance(end, camPos) if you want constant screen thickness instead. Reference:
RocketTurret src/ItemActionRocketTurretPointer.cs (RocketTurretLaserView builds
the quad; EnsureLaser creates the mesh). Airstrike still uses the LineRenderer
- TransformZ path and exhibits the wedge — port the quad approach there too if it's ever noticed.
Surface-hit data, if you ever need the normal instead: 7DTD's voxel raycast
returns Voxel.voxelRayHitInfo (WorldRayHitInfo); .hit is a HitInfoDetails
struct with Vector3 pos, Vector3i blockPos, BlockFace blockFace,
distanceSq — no Vector3 normal field, so derive a normal from blockFace
(Top=+Y, Bottom=−Y, North=+Z, South=−Z, East=+X, West=−X; Middle/None = unknown).
Reusing a vanilla explosion VFX/SFX without doing damage
You don't have to hand-roll an explosion — the game's explosion prefabs are
loadable at runtime and ship the flames, smoke, scorch decal, dynamic light and
the boom AudioSource all in one. WorldStaticData.prefabExplosions is a
Transform[100] indexed by the Explosion.ParticleIndex items use (e.g. 13 =
thrown-grenade explosion). They're loaded from Addressables
(Prefabs/prefabExplosion{i}.prefab) during world load, so the array may still
be null/empty before a world is up — guard for it.
GameManager.ExplosionClient(...) is what spawns one for a real explosion, but
it also runs ApplyExplosionForce.Explode and QuestEventManager.DetectedExplosion.
For a purely cosmetic blast (you apply your own entity damage/knockback, or
want zero gameplay effect), skip it and instantiate the prefab yourself:
Transform[] fx = WorldStaticData.prefabExplosions;
if (fx != null && idx > 0 && idx < fx.Length && fx[idx] != null) {
GameObject go = Object.Instantiate(fx[idx].gameObject, worldPos - Origin.position, Quaternion.identity);
// the boom rides the prefab as a PlayOnAwake AudioSource — instantiating plays it.
Object.Destroy(go, 5f); // flames last ~1-2s; rest is idle tail
}
The explosion sound is not played in code — the explode path (ItemClassTimeBomb
→ ExplosionServer → ExplosionClient) never calls Audio.Manager; the boom is
an AudioSource on the prefab itself. So instantiating the prefab gives flames and
sound for free. (If you ever hit a silent instance, GetComponentsInChildren<AudioSource>
and .Play() the non-playing ones.)
Perf gotcha #1 — strip the dynamic Light when spawning many at once. Each prefab
carries a real-time point Light. A handful is fine; dozens spawned within a
fraction of a second (e.g. an airstrike landing ~24 impacts in ~0.4s) tanks FPS —
simultaneous real-time lights are a top cost. Strip them and the flames still read:
foreach (var l in go.GetComponentsInChildren<Light>(true)) Object.Destroy(l);
Perf gotcha #2 — the smoke/dust particle systems are overdraw bombs. With the lights gone, the next-biggest cost is fill-rate from the big semi-transparent smoke/dust/dirt clouds. They're large quads that overlap heavily when many blasts stack, blowing out overdraw. You can keep only the flames by destroying the smoke/dust child systems by name and leaving the fire/spark/glow ones:
string[] cut = { "smoke", "dust", "dirt", "debris", "twirl", "ground" };
foreach (var ps in go.GetComponentsInChildren<ParticleSystem>(true)) {
var n = ps.gameObject.name.ToLowerInvariant();
if (System.Array.Exists(cut, k => n.Contains(k))) Object.Destroy(ps.gameObject);
}
Anatomy of prefabExplosion13 (thrown-grenade), so you know what you're cutting
Root prefabExplosion13 has no AudioSource — two MonoBehaviours instead:
AudioPlayer { soundName="explosion_grenade", playOnDemand=0 } (plays the boom on
spawn via the sound system, not an AudioSource — so don't go hunting for one) and
TemporaryObject { life≈4.1 } (self-destructs, so an explicit Destroy(go, t) is
only a safety net). Under child v021, the particle systems are:
| Keep (flames/sparks/glow — cheap) | Cut (smoke/dust/debris — overdraw) |
|---|---|
coreBlast_mainBlast (fireball) | coreBlast_mainBlastSmokeFill |
coreBlast_centerBlast | coreBlast_outwardDust_blast |
coreBlast_muzzleBlast | coreBlast_outwardDust_secondaryBlast |
sparksSource / part_Sparks_A_pS | coreBlast_dirt |
p_light (additive glow card) | coreBlast_twirl_A / coreBlast_twirl_B |
grenadeDebris | |
groundDetector_card_C / groundBlast_C |
The coreBlast_* / *Smoke* / *Dust* naming is a shared authoring template across
the explosion prefabs, so the keyword filter generalises to other indices.
Making a flash linger. The flame systems emit a single burst at t=0, so what
you see is bounded by each particle's startLifetime (~0.7s for the grenade's core
flame), not the system's duration (which is 3s but emits nothing after the burst).
To stretch the visible fire you must touch lifetime, not duration.
Two levers, with a sharp gotcha on the first:
TemporaryObject.SetLife(float seconds)(public, on the explosion's root) — the game's own rescale: multiplies each qualifying child'sduration/startDelay/startLifetimeand sets the object'slife. Gotcha: it derives ONE global factornum3 = (life-1) / maxChildDuration, and it skips systems whose duration< maxChildDuration*0.5. OnprefabExplosion13a spark system hasduration=5, somaxChildDuration=5andSetLife(8)yields only7/5 = 1.4×— the flames barely change. So SetLife is unreliable whenever child durations vary.ps.main.simulationSpeed *= 0.33fon each kept system — slows every system by the same factor (a particle living 0.7s now shows for ~2.1s), preserving the authored look. Side effect: the burst expands in slow motion too (mild bullet-time), which for fire reads fine. simulationSpeed does not extend the GO's life, so also widen it: settemp.life = Ndirectly (its destroy coroutine readslifefromStart(), which runs after your post-Instantiate code, so the field is picked up — noRestart()needed) and keep a backstopObject.Destroy(go, N + margin).
For Airstrike, simulationSpeed + temp.life won out over SetLife for exactly the
varying-duration reason above.
Inspecting a prefab offline (UnityPy). The prefabs live in
Data/Addressables/Standalone/prefabs_assets_all.bundle (GameObject
prefabExplosion{i}); explosion sounds are in automatic_assets_sounds/explosions.bundle.
UnityPy.load(bundle) → walk a GameObject's m_Component (deref PPtr m_PathID
against an id→obj map) and Transform.m_Children to print the hierarchy; read a
MonoBehaviour's m_Script.m_ClassName and read_typetree() for its fields.
Reference: Airstrike src/AirstrikeExplosionFx.cs (lethal cannon impacts spawn the
grenade explosion, light-stripped + smoke/dust-pruned; entity damage applied in code).
Loading a shipped PNG at runtime for an OnGUI overlay
Sometimes you want a real image (a product photo, a UI overlay) rather than a
procedural texture. You can ship a .png in the mod and decode it at runtime —
but two non-obvious things bite:
1. Texture2D.LoadImage(byte[]) needs an extra assembly reference. It is an
extension method in UnityEngine.ImageConversionModule, not CoreModule.
Without the reference the build fails with CS1061: 'Texture2D' does not contain a definition for 'LoadImage'. Add to the .csproj:
<Reference Include="UnityEngine.ImageConversionModule">
<HintPath>$(ManagedDir)\UnityEngine.ImageConversionModule.dll</HintPath>
<Private>false</Private>
</Reference>
var tex = new Texture2D(2, 2, TextureFormat.RGBA32, false); // size is replaced by LoadImage
if (tex.LoadImage(File.ReadAllBytes(path))) { tex.wrapMode = TextureWrapMode.Clamp; ... }
2. Find the file via the mod path, not a relative path. Capture Mod.Path
in your IModApi.InitMod (ModPath = _modInstance.Path;) and
Path.Combine(ModPath, "Resources", "name.png"). The CWD at runtime is the game
dir, so a relative path won't resolve. PNG load is a per-file decode, not an
Addressables/bundle lookup — no #@modfolder: syntax needed (that's only for
.unity3d bundles, see Asset Bundles).
3. deploy.ps1 copies an explicit allow-list of files — add new assets to it.
The script enumerates each file/folder it copies (ModInfo, dll, Config, the
bundle, …); a newly-added PNG under resources/ is not picked up unless you
add a Copy-Item for it. Symptom: works in dev, missing in the deployed mod, and
the File.Exists guard logs "not found". Cache the decoded texture in a static
and latch failures so you don't retry the disk read every activation. (As with
procedural textures, set HideAndDontSave if it must survive world transitions.)
Keying a white background to alpha (Pillow). To turn a product photo on a white background into a transparent-surround overlay, flood-fill from the four corners with a sentinel colour (so only background-connected white is keyed — interior highlights survive), build the alpha from that mask, and feather with a ~0.8px Gaussian blur to kill the silhouette halo:
flood = im.copy()
for c in [(0,0),(w-1,0),(0,h-1),(w-1,h-1)]:
ImageDraw.floodfill(flood, c, (255,0,255), thresh=32)
bg = np.all(np.array(flood) == (255,0,255), axis=-1)
alpha = Image.fromarray(np.where(bg,0,255).astype("uint8")).filter(ImageFilter.GaussianBlur(0.8))
Slide-in + crossfade in OnGUI. Stamp a start time when the intro begins,
then in OnGUI (inside the 1080p virtual canvas) lerp the image's Y from
above-screen to a rest pose with an ease-out-back (overshoot+settle gives a
hand-pulled feel), and drive a black backdrop's + the image's alpha down together
for a quick crossfade that reveals what's underneath. To "fill the screen" with a
product photo that has a transparent margin, draw it larger than the canvas
(e.g. 1.5× canvas width, square) and position by a feature anchor (lens-centre
fraction) rather than the image centre, so the subject lands where the eye expects
it and the margins crop off-screen.
Hide a hard cut behind the opaque overlay. A camera/POV switch (e.g. 7DTD's
first↔third-person swap + a new RenderTexture feed) is a jarring instant cut. If
an opaque full-screen overlay is mid-animation, defer the switch until the overlay
fully covers the screen, do the cut behind it, then crossfade the overlay away
— the viewer never sees the seam. In FPV the drone holds its hover pose in a
dedicated GogglesIntro launch-phase (normal player view still rendering) while
the goggles slide down; only at slide-end (goggles fully down + blackout at max)
does ActivateFpvView run, then the crossfade dissolves the goggles into the live
feed. Reference: FPV src/FPVDroneManager.cs (BeginGogglesIntro,
UpdateGogglesIntro, DrawGogglesIntro, EaseOutBack).
Wireframe line meshes are stuck at 1px — thicken with crossed quads
A debug/edit wireframe drawn as a Mesh with MeshTopology.Lines (or
LineStrip) always rasterises exactly 1 pixel wide — there is no width
knob anywhere (material, renderer, or mesh). To make such lines thicker,
rebuild each segment as two crossed quads (a + cross-section) with a
small world-space half-width, using MeshTopology.Quads. For axis-aligned
edges the two perpendicular extrusion axes come from
Vector3.Cross(dir, Vector3.up) (falling back to Vector3.right for vertical
edges) and Vector3.Cross(dir, a). With _Cull Off on the material
(Hidden/Internal-Colored, see the laser section) the cross reads as a solid
thick line from every angle — no billboarding needed, so a static mesh works
and nothing is rebuilt per frame. Verts go 2 → 8 per dash, so set
mesh.indexFormat = IndexFormat.UInt32 if boxes can get big. Note the
thickness is world-space (thins with distance), unlike the constant-1px line
topology. Reference: Elevator src/ElevatorBoundsRenderer.cs
(AddThickDashedEdge; the green floor boxes still use the 1px line path in
AddDashedBox).
Dot-matrix LED displays: one vertex-coloured mesh, not N GameObjects
To render a dot-matrix readout (elevator floor indicator, scoreboard, etc.)
don't spawn a GameObject per LED — build one mesh with a small quad per
cell and pick each dot's colour via mesh.colors32 vertex colours.
Sprites/Default multiplies vertex colour × texture on a plain
MeshRenderer, so lit (bright) and off (faint dark) dots share one material
and one draw call; set the material colour to white and let the verts carry
the tint. Two supporting tricks:
- Dot sprite: a procedural radial-falloff
Texture2D(solid core, alpha→0 at the quad edge, squared to ease the rim). The same texture on a ~2× larger quad with low-alpha vertex colour doubles as a bloom halo under each lit dot. - Layering inside one transparent mesh: triangles of the same material render in index order, so append all halo quads first and all dot cores after — the cores draw crisply on top of the blur with no extra render queue or second material.
Glyphs are easiest as 5×7 string[] bitmaps (".###." rows) stamped into a
bool[cols, rows] grid, then swept into the mesh. Reference: Elevator
src/ElevatorPanelView.cs (MakeDotDisplay, MakeDotTexture, Glyphs).
World → scene space
Effect positions must be converted from logical world coords to scene space by
subtracting Origin.position (the floating-origin offset) before you place a
GameObject: go.transform.position = worldPos - Origin.position;. Entity
.position is already logical-world, so a bolt between a cloud and an entity
passes cloudPos - Origin.position and entity.position - Origin.position.
Porting a reflected cross-mod effect to native (drop the dependency)
zPhone's God "Kill Everything" originally reflected into
Scepters.ScepterControlAPI.KillAllZombies and refused to run when Scepters
wasn't installed. Because the bolt + particle-material code is pure Unity (no
Scepters types), it was copied verbatim into zPhone (GodLightning.cs,
renaming LightningBolt → GodLightningBolt to avoid cross-assembly
type-name ambiguity when both mods load). The only behaviour dropped was the
"skip Scepters-enchanted pets" check, which is meaningless without Scepters.
Lesson: if a reflected cross-mod feature has no hard dependency on the other
mod's types, porting the self-contained part is cleaner than guarding every
call site on ModBridge.IsInstalled(...).
Hiding a vanilla block-entity model to re-dress it (rescan, don't snapshot)
When you replace a block's look by disabling its vanilla renderers and parenting
your own procedural meshes under the block-entity model transform (Elevator's
ElevatorPanelView does this — a plate + buttons over the vanilla control panel),
the game force-enables every renderer under that transform on each redisplay:
Chunk.SetBlockEntityRendering does GetComponentsInChildren<Renderer> + set
enabled = true, called from OnDisplayBlockEntities whenever bHasTransform && !bRenderingOn. So the hide must be re-asserted every frame, not fire-and-forget.
Gotcha: re-asserting from a snapshot of the vanilla renderers (captured once
when you first dressed the block) goes stale. The game can rebuild the block
entity's model in place — Chunk.AddEntityBlockStub swaps the
BlockEntityData for a position (old one → blockEntityStubsToRemove, pooled via
poolBlockEntityTransform), and GameObjectPool can hand the same model
GameObject back on the next display, or a paint/damage/rotation change regenerates
the mesh. A fresh set of vanilla Renderer instances then appears under the
same anchor transform. If your dead-check keys on bed.transform != anchor it
won't fire (same transform), your dressing root survives, but your captured list
no longer references the live renderers — nothing re-hides them and the vanilla
model reappears and stays ("sometimes it resets to the vanilla look" — Elevator,
fixed 2026-07-14).
Fix: each frame, rescan the anchor's current hierarchy and disable every
renderer that isn't part of your dressing (skip r.transform.IsChildOf(yourRoot)),
instead of trusting a one-time snapshot. Use the non-allocating
GetComponentsInChildren(includeInactive, List<Renderer>) overload with a static
scratch list so the per-frame scan doesn't churn GC. Panels/dressed blocks are few
per world, so a full rescan is cheap. Keep the one-time snapshot only for measuring
the vanilla footprint (LOD0 bounds), not for the hide.
Critical: gate the hide on your dressing actually being built, or you get an
invisible block. The re-hide runs every frame, typically at the top of your
per-rig refresh — before the build step that (re)creates your dressing. On a
freshly created rig, or any frame the art/build bails early (missing shader,
chunk not ready), your replacement root is still null/empty. If you hide the
vanilla renderers anyway, the result is vanilla-off + nothing-drawn = invisible
(not the graceful "keeps the vanilla look" fallback you want). The old snapshot
approach was implicitly gated — the snapshot only got set inside a successful
build — and a naive rescan-at-top-of-refresh silently drops that safety. Guard it:
if (root == null || root.transform.childCount == 0) return; before hiding. Do the
first hide inside the build itself, in the same call that draws the plate (no
early-return between capture and draw), so vanilla is never hidden without a
replacement up the same frame. This exact regression shipped and blanked the
Elevator panel (fixed 2026-07-15). Reference:
Elevator/src/ElevatorPanelView.cs (ReassertHidden, gated on Root children).