Renderable
The 3D scene. Parts and models, the camera, LOD, mesh deformation, and animation tracks. Sun and ambient lighting now live on LightingService.
local Renderable = import("Renderable")
local Vector = import("Primitives").Vector
local cube = Renderable.BasePart("Cube")
cube.Size = Vector.new(2, 2, 2)
Renderable API
Spawn a primitive part. shape is
"Cube" (default) or "Sphere".
Spawn a part backed by a ModelAsset (loaded via
Asset.GetAsset("Model", ...)). Inherits the
asset's animation clips.
Polygon reduction by quantization clustering. ratio
is a 0..1 hint (default 0.5 ≈ half the vertices). The
returned LOD inherits the source's animation clips. The
ModelAsset has no path (it was generated, not loaded), so
free it with
lod:Free() rather
than Asset.Drop.
Define a region inside a part's mesh that you can move, rotate, or resize to deform whatever vertices fall within it. See DistortionBox.
EffectVolume
Invisible 3D volume that emits sprite particles, modeled after
Roblox's ParticleEmitter. Set
CFrame + Size to describe the spawn region,
give it an image, tune the property knobs, and let it run. The
volume itself doesn't draw, only the particles do, billboarded
toward the active window's camera (win:GetViewport();
toggle off with FaceCamera = false).
Particles are simulated on the Rust heart tick and rendered as
textured quads. Each particle's color, size, and transparency
sample their respective sequences over the particle's
age / lifetime normalized to [0, 1].
local Renderable = import("Renderable")
local Asset = import("Asset")
local Primitives = import("Primitives")
local spark = Asset.GetAsset("Image", "sfx.spark")
local emitter = Renderable.EffectVolume(spark)
emitter.CFrame = Primitives.CFrame.new(Primitives.Vector.new(0, 2, 0))
emitter.Size = Primitives.Vector.new(1, 1, 1)
emitter.Rate = 60
emitter.Lifetime = { Min = 0.5, Max = 1.5 }
emitter.Speed = { Min = 2, Max = 5 }
emitter.Spread = 25 -- cone half-angle in degrees
emitter.Acceleration = Primitives.Vector.new(0, -9.8, 0)
emitter.TimeScaleTransparency = { [0] = 0, [1] = 1 } -- fade out
emitter.SizeOverLife = { [0] = 1, [1] = 0.3 }
Properties
| Property | Type | Default | Notes |
|---|---|---|---|
CFrame | CFrame | identity | Spawn region center + rotation. |
Size | Vector | (1, 1, 1) | Full extent of the spawn box, in local axes. |
Image | ImageAsset / DynImg / DrawableImg? | — | Sprite texture. Read returns a boolean (whether one is set). |
Enabled | boolean | true | While false, no new particles spawn; live ones finish. |
FaceCamera | boolean | true | Billboard toward the camera. Set false for fixed-orientation sprites. |
Rate | number | 20 | Particles/sec. |
MaxParticles | number | 2048 | Hard cap; new spawns drop above this. |
Lifetime | { Min, Max } | 1..2 s | Per-particle. Accepts a plain number too. |
Speed | { Min, Max } | 2..4 | Initial speed range. |
ParticleSize | { Min, Max } | 0.5..0.5 | Base sprite size; multiplied by SizeOverLife. |
Rotation | { Min, Max } | 0..0 | Initial roll, radians. |
RotSpeed | { Min, Max } | 0..0 | Angular velocity, radians/sec. |
Acceleration | Vector | (0, 0, 0) | World-space constant force applied to every particle equally. |
RandomizeForce | { X?, Y?, Z? } | all zero | Per-particle randomized force. Each axis is an independent {min, max} range sampled once at spawn and added to Acceleration each tick. Missing axes default to {0, 0}. Example: { X = {min = -2, max = 2}, Y = {min = 1, max = 5} }. |
Drag | number | 0 | Velocity damping per second. |
Spread | number | 0 | Cone half-angle in degrees around EmissionDirection. |
EmissionDirection | Vector | (0, 1, 0) | Initial velocity direction, in volume-local space. |
Color / TimeScaleColor | Color3 or sequence | white | Aliases. Sequence input formats below. |
Transparency / TimeScaleTransparency | number or sequence | 0 | Aliases. |
SizeOverLife | number or sequence | 1 | Multiplier applied to ParticleSize. |
Sequence input formats
Both Color / TimeScaleColor and
Transparency / TimeScaleTransparency /
SizeOverLife accept three input shapes:
-- 1) Keyed-table (preferred): time -> value.
emitter.TimeScaleColor = { [0] = white, [0.5] = red, [1] = black }
emitter.TimeScaleTransparency = { [0] = 0, [1] = 1 }
-- 2) Ordered list of { t, value } pairs.
emitter.Color = { { 0, white }, { 1, black } }
-- 3) Single constant.
emitter.Transparency = 0.4
Methods
Force-spawn n particles right now (default
1), ignoring Rate. Still bounded by
MaxParticles.
Kill every alive particle immediately.
Particle shaders use a 2D-style WGSL contract
(the same kind of shader you'd attach to a
GUI primitive), not
the 3D BasePart contract. Particles are always rendered as
flat textured quads, so a 2D fragment shader is the natural
fit. See Particle shaders
below for the binding layout and the input
VsOut you have access to.
Particle shaders
Both EffectVolume (3D) and
UIEffectVolume (2D) use the
same particle WGSL contract. The vertex shader is
fixed (Ruzit handles billboarding for 3D and screen-space placement
for 2D); you write the fragment, exactly like a GUI 2D primitive
shader. Pass a ShaderAsset or FragmentAsset
to :AttachShader(asset).
Inputs (read-only in your fragment)
| Symbol | Type | Meaning |
|---|---|---|
in.uv | vec2<f32> | Sprite UV in [0, 1] (per pixel). |
in.color | vec4<f32> | Per-particle RGBA: rgb sampled from Color/TimeScaleColor, a = (1 − Transparency). |
in.life_t | f32 | This particle's normalized age, 0 = just spawned, 1 = about to die. (3D only; 0 on 2D.) |
IMG | texture_2d | The volume's Image. White 1×1 if none. |
IMG_SAMP | sampler | Linear filtering, clamp-to-edge. |
F.viewport | vec4<f32> | (width, height, time, _) — 3D only. |
F.resolution | vec4<f32> | (width, height, time, _) — 2D only. |
Output
@fragment fn fs_main(in: VsOut) -> @location(0) vec4<f32>
returns the final RGBA. Pre-multiplied alpha blending is on.
Minimal example
// A particle shader that pulses brightness over each particle's life.
@fragment
fn fs_main(in: VsOut) -> @location(0) vec4<f32> {
let tex = textureSample(IMG, IMG_SAMP, in.uv);
let pulse = 0.5 + 0.5 * sin(in.life_t * 6.28318);
let rgb = tex.rgb * in.color.rgb * pulse;
return vec4<f32>(rgb, tex.a * in.color.a);
}
What's NOT in the particle contract (yet)
-
// @ruzit paramvalues you push via:SetData(asset, name, value)are stored on the CPU but not bound to the GPU for particle shaders yet — they have no visual effect. Usein.life_t+ per-particlein.color+ the time field (F.viewport.zin 3D,F.resolution.zin 2D) instead. Param uploads land in a follow-up. -
The 3D
BasePartshader uniforms (I.model, world position, normals, lighting) are not available — particles are 2D quads, not 3D meshes. Don't reuse a BasePart shader on anEffectVolume; write a particle-style one instead.
DistortionBox
Lightweight, ad-hoc bones. Wrap an arbitrary region of a part's
mesh with a box, then mutate the box's CFrame and
Size at runtime to move / rotate / scale every
captured vertex together. The classic uses are rotating a head,
blinking eyelids, customising a face without authoring a real
skeleton, or stretching limbs without per-bone rigging.
Coordinate system
The cframe and size arguments are in the
part's local mesh space — the raw vertex
coordinates from the asset, BEFORE the engine multiplies by
part.Size and part.CFrame at render
time. For procedural shapes ("Cube",
"Sphere") the mesh is the unit cube
[-0.5, 0.5]; for loaded models it's whatever extents
the source .obj / .fbx exported —
typically [-1, 1] but it can be much larger or
smaller. So a box centred at (0, 0.4, 0) with size
(0.6, 0.3, 0.6) covers the top of a unit cube.
part.Size, part.CFrame, and the world
position of the part don't change where you place the box.
Common symptoms of getting this wrong: every box reports
VertexCount = 0 (mesh untouched), or the mesh
appears to vanish (vertices flung millions of units off-screen
because MOVE_AMP was scaled to the wrong unit).
Easiest way to get the right scale: ask the model itself with
asset:LocalBounds(),
which returns the AABB { Min, Max, Size, Center } in
mesh-local units. Size your boxes and animation amplitudes as
fractions of bounds.Size and they'll always be
proportional to whatever the source file was exported at.
local b = baseAsset:LocalBounds()
print(b.Size) -- e.g. Vector(2.1, 4.0, 0.8) for a tall thin coffin
-- Cover the entire mesh, centred where it actually is:
local whole = Renderable.DistortionBox(part, CFrame.new(b.Center), b.Size * 1.05)
-- Or grab just the top slab and wobble it. MOVE_AMP scales to the mesh too.
local topY = b.Center.Y + b.Size.Y * 0.25
local MOVE_AMP = b.Size.Y * 0.15
local top = Renderable.DistortionBox(
part,
CFrame.new(Vector.new(b.Center.X, topY, b.Center.Z)),
Vector.new(b.Size.X * 1.1, b.Size.Y * 0.5, b.Size.Z * 1.1)
)
If VertexCount = 0 on a freshly created box, the box
is outside the mesh in local space — double-check
b.Min / b.Max. If the box captures
correctly but the mesh visibly disappears once you start animating,
your MOVE_AMP is too large for the mesh scale
— scale it down as a fraction of b.Size.
Lifecycle
- Creation: every vertex whose model-space
position falls inside the rotated box is captured. Each
captured vertex stores its position relative to the box's
frame, normalised to
[-0.5, 0.5]per axis. - Mutation: write to
box.CFrameorbox.Size. Each tick the engine re-applies those captured offsets to the box's current frame, displacing every captured vertex in the part's deformed mesh. - Destroy:
box:Destroy()freezes the box at its current pose. Captured vertices stay where they are; furtherCFrame/Sizewrites are ignored. The mesh is not restored — this is intentional so chained / nested boxes don't snap back when one is removed.
Multiple boxes
Several boxes can coexist on the same part. The engine applies them in creation order; later boxes override earlier ones for any vertex captured by both. So the natural pattern for a face is: create the head box first (covers the whole head), then each eyelid / brow box second (smaller, overrides the head box's claim on those vertices). Animations run before distortion, so a captured vertex inside an active distortion box ignores its baseline animation contribution.
Properties
Current centre + rotation in part-local space. Writable. Setting it transforms captured vertices on the next tick.
Current full extent on each axis. Writable. Scales captured vertices along the box's local axes.
The box's CFrame at creation. The reference frame for
captured vertex offsets. An identity transform
(CFrame == InitialCFrame and
Size == InitialSize) leaves vertices
untouched.
Number of vertices the box captured at creation. If this is 0 the box was placed where no mesh geometry lives; check your CFrame / Size against the model's actual extents.
False after :Destroy(). The box still
applies its last transform every tick, but won't accept
further mutations.
Methods
Freeze the box. Captured vertices stay at their current
displaced positions; subsequent property writes are
ignored. Mesh is not restored — recreate a fresh
box at InitialCFrame /
InitialSize if you need to undo the
distortion.
Example: head + eyelids
local Renderable = import("Renderable")
local Asset = import("Asset")
local body = Renderable.BaseModel(Asset.GetAsset("Model", "character"))
-- Head: covers the top of the model. Use this to rotate the head.
local head = Renderable.DistortionBox(
body,
CFrame.new(Vector.new(0, 0.45, 0), Vector.new(0, 0, 0)),
Vector.new(0.5, 0.3, 0.5)
)
-- Eyelids: smaller boxes inside the head. Order matters — these
-- come after `head` so they override head's claim on those vertices.
local lid_l = Renderable.DistortionBox(
body,
CFrame.new(Vector.new(-0.1, 0.5, 0.22), Vector.new(0, 0, 0)),
Vector.new(0.08, 0.04, 0.05)
)
-- Turn the head 30° to the right.
head.CFrame = CFrame.new(head.InitialCFrame.Position, Vector.new(0, math.rad(30), 0))
-- Blink: squash the lid box flat.
lid_l.Size = Vector.new(0.08, 0.005, 0.05)
BasePart
The world-space drawable. Created via Renderable.BasePart
or BaseModel. All transforms are immutable assignments
, setting part.CFrame = ... swaps the whole pose
atomically.
Properties
Geometry kind. "Cube" / "Sphere"
use a built-in mesh; "Model" means the part was
created via Renderable.BaseModel(asset).
World pose. Writable.
Object-space scale, in world units.
Albedo when no Texture is set; tint multiplier when one is.
Set false to keep the part in the scene graph but skip drawing it.
Optional surface texture. Uses .Color as a tint.
Render hints surfaced to user shaders as
I.cast_shadow / I.receive_shadow (u32
0/1). Defaults: both true.
GPU.Raycast skips this part. Default
false.
Fires with the property name on every mutation:
"CFrame", "Size", "Color",
"Render", "Texture",
"CastShadow", "ReceiveShadow",
"IgnoreInRaycast", or "Destroyed".
Methods
Per-property change signal. Fires with the new
value as its only argument every time
propName is set — "CFrame"
fires with a CFrame, "Color" with a Color3,
"Render" / "CastShadow" /
"ReceiveShadow" /
"IgnoreInRaycast" with booleans, etc. Lazily
created: properties no one listens to cost zero. When the
last connection disconnects, per-tick firing stops
entirely.
For parts wrapped by PhysicsService,
the "CFrame" signal also fires each frame the
simulation moves the part — same lazy listener-count
gating, so unobserved physics objects don't pay any
Lua-side cost per tick. (BasePart only exposes
.CFrame, not separate Position or
Rotation — if you want to react to one
axis of motion, decompose cf.Position /
cf.Rotation inside your handler.)
part:GetPropertyChanged("CFrame"):Connect(function(cf)
print("moved to", cf.Position)
end)
Remove from the scene. Connections to Changed
still receive one final "Destroyed" fire.
Attach a ShaderAsset or FragmentAsset.
See Shaders for the WGSL contract.
Remove a previously-attached shader. Looks up by asset id; no-op if the shader isn't attached.
Detach every shader on this part.
Set a // @ruzit param slot on an attached shader
to a number.
Read a // @ruzit param slot. Returns nil if the
shader isn't attached or the name isn't declared.
Mesh deformation
Push every vertex within envelope units of
target.Position by displacement *
smoothstep(distance / envelope). Smooth radial falloff;
per-Part copy of the mesh, so deforming one Part doesn't
affect others sharing the same source asset. Returns the
number of vertices touched.
Drop the per-Part vertex override and snap back to the source mesh.
Local / world helpers
Convert an offset in the part's local frame to a
world-space position. Vector.new(0, 3, 0)
returns the world point 3 units along the part's
rotated local Y axis. Useful for placing emitters,
nameplates, and child anchors relative to a moving /
rotating part.
Inverse of LocalToWorld. Convert a world-
space position into the part's local frame.
Animation tracks
Bind an
Animation
(pulled from an
AnimationSet
via set:GetAnimation(name)) to this part and
return a playable
AnimationTrack. The same
Animation can be bound to many parts —
keyframes are shared, but each track has its own playback
cursor / Play / Stop state.
local anims = Asset.GetAsset("AnimationSet", "chars.hero_anims")
local walk = anims:GetAnimation("Walk")
local track = hero:GetAnimatedTrack(walk)
track.Looped = true
track:Play()
Every track currently bound to this part (each one came
from a prior :GetAnimatedTrack call).
Non-functional as of 1.2.8. Calling it raises a runtime
error. Models no longer carry their own animations —
load an
AnimationSet
and bind with part:GetAnimatedTrack(animation).
Anchor an SFX Sound or
VoiceChannel to this part.
While linked, the engine copies the part's
CFrame.Position into the audio's
Position property each heart tick, so the sound
follows the part automatically. Saves you from doing the
per-frame copy in Lua.
For SFX: the link writes snd.Position, preserving
MinFalloff / MaxFalloff. If the
sound had no Position, defaults 0 / 20
are installed on first sync. For VoiceChannel: writes the
channel's Spatial shader the same way (defaults 0
/ 8). Linking the same audio source again
replaces the previous binding.
Destroying the part automatically drops the link. The audio keeps its last-synced position frozen.
local snd = SFX.LoadSound(boom_data)
snd.MaxFalloff = 50
projectile:LinkInput(snd)
snd:Play() -- audio now follows `projectile` in 3D
Remove an earlier LinkInput binding. The audio's
Position stops being driven by the part and
stays wherever it was on the last synced tick. Use
audio.Position = nil after to revert to
non-positional playback.
AnimationTrack
Tracks come from one of two places:
-
part:GetAnimatedTrack(animation), whereanimationwas pulled from anAnimationSet. The track is pre-loaded with translation / rotation keyframes from the matching FBX AnimationStack. -
Author-built — bind an Animation, then call
track:Reset()+track:AddKeyframe(...)to overwrite the imported curves with fully scripted keyframes.
Properties
Cross-fade duration in seconds when blending into / out of this track. Currently informational, full blending is future work.
When true and the playhead reaches Length, time wraps to 0 and the part is restored to the captured baseline before replaying.
Time multiplier. 1.0 = normal speed; -1 reverses.
Conflict-resolution weight when multiple tracks are playing
on the same part. Higher wins; default is
0. Tracks on different parts never conflict
— each part runs its own priority gate.
-
Lower priority → output suppressed.
A walk loop at
Priority = 0will keep ticking (its clock advances, signals still fire) while a shoot animation atPriority = 1drives the visual output. When the shoot ends, walk's output takes over automatically — no manual stop / play needed. - Equal priority → merged. Two tracks tied at the current max contribute together: CFrame samples are averaged; vertex deformations are summed as displacements relative to the base mesh. So overlapping rig animations stack instead of fighting.
local walk = char:GetAnimatedTrack(walkAnim); walk.Looped = true
local shoot = char:GetAnimatedTrack(shootAnim)
shoot.Priority = 1 -- one-shot shoot pose overrides walk while playing
walk:Play()
shoot:Play() -- you see the shoot pose, but walk keeps its clock
Current playhead. Writable to scrub.
Signals
Fires when :Play() is called and the track
starts feeding samples.
Fires when the track stops, either naturally at the end
(when Looped is false) or because
:Stop() was called.
Fires every time a looped track wraps from end to start.
Only fires when Looped is true.
Methods
Capture a fresh baseline (so Reset has somewhere to restore to)
and start ticking. Calling on a paused track resets and restarts;
use Resume() to continue from a pause.
Like Pause, but rewinds to time 0. The visible pose is left in place, call Reset to snap back to baseline.
Restore the captured baseline (CFrame + mesh) and rewind.
Author a keyframe. kind is "CFrame"
(followed by the target CFrame) or "Deform"
(followed by cframe, envelope, displacement).
Returns a Signal that fires every interval
track-time seconds with the elapsed playback time.
Useful for syncing footstep sounds, particle bursts, or
gameplay events to specific moments in an animation.
Mirrors Sound:LinkToUpdate.