Objects
A misc library of useful non-specific composition objects.
ClonableContainer deep-clones its children;
Movable and Sizable are transform groupers
that move or scale every child relative to that child's individual
baseline.
local Objects = import("Objects")
local rack = Objects.Sizable()
rack:AddChild(crateA)
rack:AddChild(crateB)
rack.Size = Vector.new(0.5, 0.5, 0.5) -- crates shrink to half size
local copy = Objects.ClonableContainer()
copy:AddChild(playerModel)
local npc = copy:Clone() -- new player with the same shaders / deformations
Shared behavior
All three container types share:
- Children added later don't see prior transforms. Adding a part to a container is a snapshot — the child's current position / size is left alone. Only subsequent moves / resizes after the add propagate as deltas. So if you shrink a container, then add a fresh child, then shrink again: the old child shrinks twice, the new child only once.
- Containers can nest.
Movablecan containMovable/Sizablechildren; same forSizable. Moving / scaling the outer container propagates recursively through inner containers down to the actual leaf BaseParts / GUI primitives. The only nesting that's forbidden isClonableContainerinsideClonableContainer(you can put aMovable/Sizableinside aClonable, but not another Clonable). - Children are not destroyed when the container is.
:Destroy()tears down the container's child list but leaves every child userdata alive. Pass aremoveFnto:AddChild(child, removeFn)if you want a hook on destruction (gets called once with the child as its only argument). - Type-locking.
MovableandSizableauto-detect 2D vs 3D from the first:AddChildcall (including any nested container's leaf mode). After that, the wrong type errors on add.ClonableContainerhappily mixes both. :GetChildren()returns a Lua array of every alive child as fresh userdata wrappers.:RemoveChild(child)detaches a child from the container's list. Returnstrueif found. Fires the child'sremoveFncallback if one was registered at:AddChildtime. The child userdata itself is left alive.
Movable / Sizable: inherit-on-empty
When a Movable or Sizable is empty and
you add a leaf (BasePart / GUI primitive) or a nested container
that already has the right mode, the container adopts that
child's state as its own:
- 3D
Movable+ first BasePart child: container'sCFramesnaps to the part'sCFrame. The nextm.CFrame = Xmoves the part by exactlyX - part.CFrame— no surprise jump from origin. - 2D
Movable+ first GUI child: container'sPositionsnaps to the primitive'sPosition. - 3D
Sizable+ first BasePart child: container'sSizesnaps to the part'sSize. - 2D
Sizable+ first GUI child: container'sSizesnaps to the primitive'sSize.
Inheritance only happens when the container is empty — later adds use the current container state as the baseline (so the "shrink, add, shrink again" semantics still hold).
ClonableContainer
Attach a BasePart or GUI primitive. Optional
removeFn is invoked on :Destroy().
Deep-clone the container. Every child is re-spawned and populated with the same:
- CFrame, Size, Color, Render, Lit, CastShadow, ReceiveShadow, IgnoreInRaycast (for BaseParts)
- Position, Size, Color, Transparency, ZIndex, Visible (for GUI primitives)
- Model + texture references (shared Arcs, so the clone still shares mesh data with the original)
- Deformed mesh state (shared Arc; further deformations on one don't bleed into the other since deformation rebuilds the Arc)
- Attached shaders, each with an independent copy of its 16-float param block so updating the clone's params doesn't touch the original's
- Text content / font reference (for Text primitives)
- Nested containers (Movable / Sizable) are deep-cloned too — the clones contain fresh inner containers with fresh leaves, structurally matching the original.
- Clippable parent links are remapped:
if a GUI child was clipped by another GUI primitive
that's also in the same clone tree, the clone's
clip_parentis rewired to the clone of that parent. Clippable parents that live OUTSIDE the clone tree are dropped on the clone (the new primitives don't inherit the original's external clipping).
Detach child from the list. Returns
true on success. Fires the stored removeFn if
present, leaves the child userdata alive.
Movable
Carries either a CFrame (3D mode) or a Dim
Position (2D mode). Writing the property applies the delta to every
child, where the delta is measured from that specific child's
baseline of the container, recorded when it was added.
3D-mode only. Writes through as a per-child positional and rotational delta. Errors when the container is locked to 2D.
2D-mode only. Writes through as a per-child Dim delta. Errors when the container is locked to 3D.
When the Movable is empty and the child carries a usable transform (BasePart CFrame, GUI Position, or a nested container of the same mode), the Movable inherits that value as its own current state. The first add is always a no-op move; later adds use the current container state as the baseline.
Detach a child. Returns true on success. Fires the stored removeFn if registered, leaves the child alive.
Sizable
Carries a Size property of either type
Vector (3D mode) or Dim (2D mode). Writing
it scales every child by the per-axis ratio of Size to
the child's baseline container size. Heterogeneous: a tiny child
added to a small container scales the same proportionally as a big
child added later.
3D-mode containers accept either a userdata
Vector or a Luau native vec3 (the
engine's value_to_vector_opt handles the
unification). 2D-mode containers accept a Dim.
Passing the wrong type for the locked mode errors.
When the Sizable is empty, inherits the new child's Size (BasePart Vector, GUI Dim, or a nested Sizable's Size) so the next resize applies as a clean ratio from there.
Detach a child. Returns true on success. Fires the stored removeFn if registered, leaves the child alive.
Billboard
World-anchored UI surface. Link GUI primitives to a Billboard with
:AddChild(primitive) and they render in the 3D scene at
the Billboard's Position instead of on screen. Each
linked primitive's Position is its offset (in pixels)
inside the Billboard's local canvas; the canvas itself is centered
on the world point. Behind-camera Billboards are culled.
A primitive can be linked to at most one Billboard
at a time. Re-linking it to a different Billboard errors. Destroying
a Billboard unlinks every child — those primitives fall back to
normal screen-space rendering at whatever Position they
currently hold.
local Objects = import("Objects")
local GUI = import("GUI")
local Asset = import("Asset")
local Vector = import("Primitives").Vector
local Dim = import("Primitives").Dim
local bb = Objects.Billboard()
bb.Position = Vector.new(5, 3, 0)
bb.Size = Dim.new(300, 120)
local bg = GUI.Basic.Square()
bg.Size = Dim.new(300, 120)
bg.Position = Dim.new(0, 0) -- top-left of canvas
bb:AddChild(bg)
local name = GUI.Basic.Font(Asset.GetAsset("Font", "ui.body"))
name.Position = Dim.new(12, 12)
bb:AddChild(name)
Properties
| Property | Type | Default | Notes |
|---|---|---|---|
Position | Vector | (0, 0, 0) | World-space anchor. Canvas is centered on this point's screen projection. |
Size | Dim | (300, 300) | Pixel canvas size. Child positions are offsets inside it (0,0 = top-left). |
ScaleWithCamera | boolean | false | See below. |
Alive | boolean | — | Read-only. |
ChildCount | number | — | Read-only. Drops dead refs lazily. |
ScaleWithCamera
False (default): the canvas is a fixed pixel size on screen. A 300×300 Billboard always covers 300×300 pixels regardless of camera distance — the classic Roblox-style nameplate billboard.
True: the canvas scales with distance like a real 3D object. The pixel size becomes a world-space size at the Billboard's depth, so walking closer makes it bigger and walking away makes it smaller, same falloff as a sprite in 3D.
Methods
Link a GUI primitive. Its Position becomes its
in-canvas offset. Errors if the primitive is already linked
to a different Billboard.
Unlink. The primitive returns to screen-space rendering.
Mark dead and unlink every child.
Tweening
A Billboard is a valid target for
TweenService.new.
Tweenable properties:
Position— goal is aVectorSize— goal is aDim
Movable and
Sizable are also valid tween
targets:
Movable.CFrame(3D mode, goal is aCFrame) or.Position(2D mode, goal is aDim) — children follow by delta each tick.Sizable.Size(goal is aVectorin 3D mode or aDimin 2D mode) — children scale by the ratio each tick.
UIEnvironment
Container for 3D parts that should render on top of the 2D
GUI instead of in the world. Drop a
BasePart in with
:AddChild(part) and the renderer pulls it out of the
normal world pass, then redraws it in a follow-up pass that
clears the depth buffer first. The effect: the
part always wins against world geometry (it appears in front of
everything), but still depth-tests against other parts in the
same UI overlay (so a closer cube can still occlude a farther one
inside the same UIEnvironment).
Use it for 3D-in-a-window UI: inventory previews, character portraits, model turntables in menus, anything where the part is "decoration on the HUD" rather than part of the world.
local Objects = import("Objects")
local Renderable = import("Renderable")
local Primitives = import("Primitives")
local Heart = import("RunService").Heartbeat
local ui3d = Objects.UIEnvironment()
ui3d.ZIndex = 10
local trophy = Renderable.BasePart("Cube")
trophy.Size = Primitives.Vector.new(1, 1, 1)
trophy.CFrame = Primitives.CFrame.new(Primitives.Vector.new(0, 0, -3))
ui3d:AddChild(trophy)
Heart:Connect(function(dt)
trophy.CFrame = trophy.CFrame * Primitives.CFrame.Angles(0, dt, 0)
end)
CFrame is interpreted using the same camera the
rest of the 3D scene uses. The depth-buffer clear means it'll
always win against world geometry, but it still has to be in
front of the camera (positive look-direction) or it's culled.
For a "3D in a UI window" effect, position the part close to the
camera (e.g. Z = -3) so it fills a sensible chunk
of the screen.
Properties
| Property | Type | Default | Notes |
|---|---|---|---|
ZIndex | number | 0 | Layer order. Higher = renders later (on top of lower ZIndex environments). Affects every child immediately. |
Alive | boolean | — | Read-only. |
ChildCount | number | — | Read-only. |
Methods
Adopt a BasePart
into the overlay. The part stops rendering in the world
pass on the next frame and starts rendering in the UI
overlay pass with the environment's current
ZIndex. Idempotent — adding the same part
twice has no effect.
Unlink the part. It returns to rendering in the world
pass at its current CFrame. Returns
true if the part was actually in this
environment.
Snapshot array of live children, in insertion order.
Mark dead, unlink every child. Children survive at their current world positions and resume rendering in the world pass.
Render order
Pipeline ordering each frame:
- Skybox
- World 3D parts (depth-tested, deepest first)
- 3D particles
- 2D GUI items (z-index sorted)
-
UI overlay 3D parts — in a fresh render
pass that clears the depth buffer, sorted by each
environment's
ZIndexascending (higherZIndexdraws later, so it appears on top of lower ones). - Optional post-effect pass
UIEnvironment at a time —
:AddChild on a second environment just moves it
(no error, but the first environment forgets about it next
frame). If you need true multi-targeting, clone the part with
ClonableContainer first.