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

Renderable.BasePart(shape?) -> BasePart

Spawn a primitive part. shape is "Cube" (default) or "Sphere".

Renderable.BaseModel(asset) -> BasePart

Spawn a part backed by a ModelAsset (loaded via Asset.GetAsset("Model", ...)). Inherits the asset's animation clips.

Renderable.SimplifyMesh(asset, ratio?) -> ModelAsset

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.

Renderable.DistortionBox(part, cframe, size) DistortionBox

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

PropertyTypeDefaultNotes
CFrameCFrameidentitySpawn region center + rotation.
SizeVector(1, 1, 1)Full extent of the spawn box, in local axes.
ImageImageAsset / DynImg / DrawableImg?Sprite texture. Read returns a boolean (whether one is set).
EnabledbooleantrueWhile false, no new particles spawn; live ones finish.
FaceCamerabooleantrueBillboard toward the camera. Set false for fixed-orientation sprites.
Ratenumber20Particles/sec.
MaxParticlesnumber2048Hard cap; new spawns drop above this.
Lifetime{ Min, Max }1..2 sPer-particle. Accepts a plain number too.
Speed{ Min, Max }2..4Initial speed range.
ParticleSize{ Min, Max }0.5..0.5Base sprite size; multiplied by SizeOverLife.
Rotation{ Min, Max }0..0Initial roll, radians.
RotSpeed{ Min, Max }0..0Angular velocity, radians/sec.
AccelerationVector(0, 0, 0)World-space constant force applied to every particle equally.
RandomizeForce{ X?, Y?, Z? }all zeroPer-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} }.
Dragnumber0Velocity damping per second.
Spreadnumber0Cone half-angle in degrees around EmissionDirection.
EmissionDirectionVector(0, 1, 0)Initial velocity direction, in volume-local space.
Color / TimeScaleColorColor3 or sequencewhiteAliases. Sequence input formats below.
Transparency / TimeScaleTransparencynumber or sequence0Aliases.
SizeOverLifenumber or sequence1Multiplier 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

emitter:Emit(n?) method

Force-spawn n particles right now (default 1), ignoring Rate. Still bounded by MaxParticles.

emitter:Clear() method

Kill every alive particle immediately.

emitter:Destroy() method
emitter:AttachShader / DetachShader / ClearShaders / SetData / GetData method

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)

SymbolTypeMeaning
in.uvvec2<f32>Sprite UV in [0, 1] (per pixel).
in.colorvec4<f32>Per-particle RGBA: rgb sampled from Color/TimeScaleColor, a = (1 − Transparency).
in.life_tf32This particle's normalized age, 0 = just spawned, 1 = about to die. (3D only; 0 on 2D.)
IMGtexture_2dThe volume's Image. White 1×1 if none.
IMG_SAMPsamplerLinear filtering, clamp-to-edge.
F.viewportvec4<f32>(width, height, time, _) — 3D only.
F.resolutionvec4<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)

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.

Don't pass world coordinates. Boxes live in the mesh's native vertex space. 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

  1. 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.
  2. Mutation: write to box.CFrame or box.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.
  3. Destroy: box:Destroy() freezes the box at its current pose. Captured vertices stay where they are; further CFrame / Size writes 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

.CFrame CFrame

Current centre + rotation in part-local space. Writable. Setting it transforms captured vertices on the next tick.

.Size Vector

Current full extent on each axis. Writable. Scales captured vertices along the box's local axes.

.InitialCFrame CFrame read-only

The box's CFrame at creation. The reference frame for captured vertex offsets. An identity transform (CFrame == InitialCFrame and Size == InitialSize) leaves vertices untouched.

.InitialSize Vector read-only
.VertexCount number read-only

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.

.IsAlive boolean read-only

False after :Destroy(). The box still applies its last transform every tick, but won't accept further mutations.

Methods

box:Destroy()

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

.Shape "Cube" | "Sphere" | "Model" read-only

Geometry kind. "Cube" / "Sphere" use a built-in mesh; "Model" means the part was created via Renderable.BaseModel(asset).

.CFrame CFrame

World pose. Writable.

.Size Vector

Object-space scale, in world units.

.Color Color3

Albedo when no Texture is set; tint multiplier when one is.

.Render boolean

Set false to keep the part in the scene graph but skip drawing it.

.Texture ImageAsset?

Optional surface texture. Uses .Color as a tint.

.CastShadow / .ReceiveShadow boolean

Render hints surfaced to user shaders as I.cast_shadow / I.receive_shadow (u32 0/1). Defaults: both true.

.IgnoreInRaycast boolean

GPU.Raycast skips this part. Default false.

.Changed Signal<string> read-only

Fires with the property name on every mutation: "CFrame", "Size", "Color", "Render", "Texture", "CastShadow", "ReceiveShadow", "IgnoreInRaycast", or "Destroyed".

Methods

part:GetPropertyChanged(propName) -> Signal<any> method new in 1.1

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)
part:Destroy() method

Remove from the scene. Connections to Changed still receive one final "Destroyed" fire.

part:AttachShader(asset) method

Attach a ShaderAsset or FragmentAsset. See Shaders for the WGSL contract.

part:DetachShader(asset) method

Remove a previously-attached shader. Looks up by asset id; no-op if the shader isn't attached.

part:ClearShaders() method

Detach every shader on this part.

part:SetData(asset, name, value) method

Set a // @ruzit param slot on an attached shader to a number.

part:GetData(asset, name) -> number? method

Read a // @ruzit param slot. Returns nil if the shader isn't attached or the name isn't declared.

Mesh deformation

part:Deform(target, envelope, displacement) -> number method

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.

part:ResetDeformation() method

Drop the per-Part vertex override and snap back to the source mesh.

Local / world helpers

part:LocalToWorld(local_offset) -> Vector method

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.

part:WorldToLocal(world) -> Vector method

Inverse of LocalToWorld. Convert a world- space position into the part's local frame.

Animation tracks

part:GetAnimatedTrack(animation) → AnimationTrack method

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()
part:GetTracks() → { AnimationTrack } method

Every track currently bound to this part (each one came from a prior :GetAnimatedTrack call).

part:GetTrack(name) deprecated

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

part:LinkInput(audio) method

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
part:UnlinkInput(audio) method

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:

Properties

.Namestringread-only
.FadeTimenumber

Cross-fade duration in seconds when blending into / out of this track. Currently informational, full blending is future work.

.Loopedboolean

When true and the playhead reaches Length, time wraps to 0 and the part is restored to the captured baseline before replaying.

.Speednumber

Time multiplier. 1.0 = normal speed; -1 reverses.

.Prioritynumber

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 = 0 will keep ticking (its clock advances, signals still fire) while a shoot animation at Priority = 1 drives 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
.Lengthnumberread-only
.TimePositionnumber

Current playhead. Writable to scrub.

.IsPlaying / .IsPausedbooleanread-only

Signals

.PlayedSignal<>read-only

Fires when :Play() is called and the track starts feeding samples.

.StoppedSignal<>read-only

Fires when the track stops, either naturally at the end (when Looped is false) or because :Stop() was called.

.DidLoopSignal<>read-only

Fires every time a looped track wraps from end to start. Only fires when Looped is true.

Methods

track:Play()method

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.

track:Pause()method
track:Resume()method
track:Stop()method

Like Pause, but rewinds to time 0. The visible pose is left in place, call Reset to snap back to baseline.

track:Reset()method

Restore the captured baseline (CFrame + mesh) and rewind.

track:AddKeyframe(time, kind, ...)method

Author a keyframe. kind is "CFrame" (followed by the target CFrame) or "Deform" (followed by cframe, envelope, displacement).

track:ClearKeyframes()method
track:LinkToUpdate(interval)-> Signal<number>method

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.