Skip to content

Instantly share code, notes, and snippets.

@Pyseph
Last active March 9, 2025 05:40
Show Gist options
  • Save Pyseph/8fee39acdb5c33d64c4df72d92cdc60a to your computer and use it in GitHub Desktop.
Save Pyseph/8fee39acdb5c33d64c4df72d92cdc60a to your computer and use it in GitHub Desktop.
get bounding box of a Roblox model including particle emitters
--!strict
-- distance traveled from t=0..lifetime if Drag>0 means half-life=drag
local function ComputeDragFactor(drag: number, lifetime: number): number
-- if Drag = 0, theres no exponential change in speed
if drag == 0 then
return 1
end
-- 'rate' = drag * ln(2). this is the exponent's coefficient in e^( ... )
local rate = drag * math.log(2)
if drag > 0 then
-- velocity decays: v(t) = v0 * 2^(-drag * t)
-- distance factor = [1 - e^(-rate * lifetime)] / (rate * lifetime)
-- this fraction is the average speed (with decay) / initial speed
local numerator = 1 - math.exp(-rate * lifetime) + math.log(2)
return numerator / (rate * lifetime)
else
-- drag < 0 => velocity grows: v(t) = v0 * 2^(|drag| * t)
-- We flip the sign in the exponent
local growthRate = -rate -- = |drag| * ln(2)
local numerator = math.exp(growthRate * lifetime) - 1
return numerator / (growthRate * lifetime)
end
end
local function GetBoundingBox(Object: Instance | {Instance}, ModelOrientation: CFrame?, Gizmo: any?)
local Orientation = ModelOrientation or CFrame.identity
local Descendants: {Instance} = if typeof(Object) == "Instance" then Object:GetDescendants() else Object
local MinX, MinY, MinZ = math.huge, math.huge, math.huge
local MaxX, MaxY, MaxZ = -math.huge, -math.huge, -math.huge
local function Update(ObjSize: Vector3, RelativeCFrame: CFrame)
local sx, sy, sz = ObjSize.X, ObjSize.Y, ObjSize.Z
local x, y, z, R00, R01, R02, R10, R11, R12, R20, R21, R22 = RelativeCFrame:GetComponents()
local wsx = (math.abs(R00) * sx + math.abs(R01) * sy + math.abs(R02) * sz) / 2
local wsy = (math.abs(R10) * sx + math.abs(R11) * sy + math.abs(R12) * sz) / 2
local wsz = (math.abs(R20) * sx + math.abs(R21) * sy + math.abs(R22) * sz) / 2
MinX = MinX > x - wsx and x - wsx or MinX
MinY = MinY > y - wsy and y - wsy or MinY
MinZ = MinZ > z - wsz and z - wsz or MinZ
MaxX = MaxX < x + wsx and x + wsx or MaxX
MaxY = MaxY < y + wsy and y + wsy or MaxY
MaxZ = MaxZ < z + wsz and z + wsz or MaxZ
end
-- update bounding box with a point in object space
local function UpdateWithObjectPoint(objPoint: Vector3, radius: number)
local x, y, z = objPoint.X, objPoint.Y, objPoint.Z
MinX = MinX > x - radius and x - radius or MinX
MinY = MinY > y - radius and y - radius or MinY
MinZ = MinZ > z - radius and z - radius or MinZ
MaxX = MaxX < x + radius and x + radius or MaxX
MaxY = MaxY < y + radius and y + radius or MaxY
MaxZ = MaxZ < z + radius and z + radius or MaxZ
end
-- sample a cone (or double-cone) of directions around BaseDirection by dividing the X/Y spread angles into small increments
local function GetSpreadDirections(BaseDirection: Vector3, SpreadXDeg: number, SpreadYDeg: number, Steps: number): {Vector3}
-- convert half‐angles to radians
local XRad = math.rad(SpreadXDeg)
local YRad = math.rad(SpreadYDeg)
-- create a CFrame whose LookVector = BaseDirection.
-- that way, rotating around X or Y in local space will rotate around the emitter’s forward direction
local BaseCFrame = CFrame.lookAt(Vector3.zero, Vector3.zero + BaseDirection)
local Directions = {}
for i = 0, Steps do
-- alpha = pitch rotation from -HalfX to +HalfX
local Alpha = -XRad + (2 * XRad) * (i / Steps)
for j = 0, Steps do
-- beta = yaw rotation from -HalfY to +HalfY
local Beta = -YRad + (2 * YRad) * (j / Steps)
-- rotate around local X, then local Y
local Rotated = BaseCFrame * CFrame.Angles(Alpha, Beta, 0)
table.insert(Directions, Rotated.LookVector)
end
end
return Directions
end
-- calculate particleemitter bounding box
local function CalculateParticleEmitterBounds(Emitter: ParticleEmitter, ParentObject: BasePart | Attachment)
local ParticleMinX, ParticleMinY, ParticleMinZ = math.huge, math.huge, math.huge
local ParticleMaxX, ParticleMaxY, ParticleMaxZ = -math.huge, -math.huge, -math.huge
local IsParentPart = ParentObject:IsA("BasePart")
local function UpdateParticleBounds(ObjPoint: Vector3, Radius: number)
local X, Y, Z = ObjPoint.X, ObjPoint.Y, ObjPoint.Z
ParticleMinX = math.min(ParticleMinX, X - Radius)
ParticleMinY = math.min(ParticleMinY, Y - Radius)
ParticleMinZ = math.min(ParticleMinZ, Z - Radius)
ParticleMaxX = math.max(ParticleMaxX, X + Radius)
ParticleMaxY = math.max(ParticleMaxY, Y + Radius)
ParticleMaxZ = math.max(ParticleMaxZ, Z + Radius)
end
-- Gather emitter parameters
local MaxSize = 0
for _, Keypoint in ipairs(Emitter.Size.Keypoints) do
MaxSize = math.max(MaxSize, Keypoint.Value)
end
local MaxSpeed = math.max(math.abs(Emitter.Speed.Min), math.abs(Emitter.Speed.Max))
local MaxLifetime = Emitter.Lifetime.Max
local BaseDirection = ({
[Enum.NormalId.Top] = Vector3.new(0, 1, 0),
[Enum.NormalId.Bottom] = Vector3.new(0, -1, 0),
[Enum.NormalId.Front] = Vector3.new(0, 0, -1),
[Enum.NormalId.Back] = Vector3.new(0, 0, 1),
[Enum.NormalId.Left] = Vector3.new(-1, 0, 0),
[Enum.NormalId.Right] = Vector3.new(1, 0, 0)
})[Emitter.EmissionDirection]
-- convert local direction to world direction (part rotation only, no translation)
local ObjectCFrame = IsParentPart and ParentObject.CFrame or (ParentObject:: Attachment).WorldCFrame
local WorldDirection = ObjectCFrame.Rotation * BaseDirection
-- get the fraction of distance traveled relative to no drag
local DragFactor = ComputeDragFactor(Emitter.Drag, MaxLifetime)
-- compute base distance from speed, lifetime, drag, acceleration, etc
local BaseDistance = MaxSpeed * MaxLifetime * DragFactor
-- roughly account for acceleration in the direction of WorldDirection
local Acceleration = Emitter.Acceleration
local AccelDot = Acceleration:Dot(WorldDirection)
if AccelDot > 0 then
BaseDistance += 0.5 * Acceleration.Magnitude * MaxLifetime * MaxLifetime
else
BaseDistance = math.max(0, BaseDistance - 0.5 * Acceleration.Magnitude * MaxLifetime * MaxLifetime)
end
-- collect the 8 corners of the part bounding box
local Corners = {}
local HalfSize = IsParentPart and (ParentObject:: BasePart).Size / 2 or Vector3.new(0.001, 0.001, 0.001)
for _, sx in ipairs({-1, 1}) do
for _, sy in ipairs({-1, 1}) do
for _, sz in ipairs({-1, 1}) do
local CornerWorldPos = (ObjectCFrame * CFrame.new(sx * HalfSize.X, sy * HalfSize.Y, sz * HalfSize.Z)).Position
table.insert(Corners, CornerWorldPos)
end
end
end
-- if full sphere, we can skip angle sampling
local IsFullSphere = (Emitter.SpreadAngle.X >= 180 and Emitter.SpreadAngle.Y >= 180)
if IsFullSphere then
-- for a full 360 degree spread, just treat it like a sphere from each corner
for _, CornerWorldPos in ipairs(Corners) do
local CornerObjPos = Orientation:PointToObjectSpace(CornerWorldPos)
UpdateWithObjectPoint(CornerObjPos, BaseDistance + MaxSize)
UpdateParticleBounds(CornerObjPos, BaseDistance + MaxSize)
end
else
-- for directional spread, sample angles around BaseDirection
local SpreadX = Emitter.SpreadAngle.X
local SpreadY = Emitter.SpreadAngle.Y
-- how many samples you want in each dimension. you can tweak this
local ANGLE_STEPS = 6
-- Precompute all possible directions
local SpreadDirections = GetSpreadDirections(WorldDirection, SpreadY, SpreadX, ANGLE_STEPS)
for _, CornerWorldPos in ipairs(Corners) do
local CornerObjPos = Orientation:PointToObjectSpace(CornerWorldPos)
-- also update with the corner itself
UpdateWithObjectPoint(CornerObjPos, MaxSize)
UpdateParticleBounds(CornerObjPos, MaxSize)
for _, Dir in ipairs(SpreadDirections) do
local SampleWorldPos = CornerWorldPos + Dir * BaseDistance
local SampleObjPos = Orientation:PointToObjectSpace(SampleWorldPos)
UpdateWithObjectPoint(SampleObjPos, MaxSize)
UpdateParticleBounds(SampleObjPos, MaxSize)
end
end
end
-- draw the bounding box for just this particle emitter
if Gizmo then
local ParticleOmin = Vector3.new(ParticleMinX, ParticleMinY, ParticleMinZ)
local ParticleOmax = Vector3.new(ParticleMaxX, ParticleMaxY, ParticleMaxZ)
local ParticleSize = ParticleOmax - ParticleOmin
if ParticleSize.Magnitude > 0 and ParticleMinX ~= math.huge then
local ParticleCenter = Orientation:PointToWorldSpace((ParticleOmax + ParticleOmin) / 2)
local ParticleCFrame = CFrame.new(ParticleCenter)
Gizmo:DrawBox(ParticleCFrame, ParticleSize, 5, Color3.fromRGB(0, 200, 255))
end
end
end
for _, Obj in Descendants do
if Obj:IsA("BasePart") then
local RelativeCFrame = Orientation:ToObjectSpace(Obj.CFrame)
local ObjSize = Obj.Size
Update(ObjSize, RelativeCFrame)
for _, Child in Obj:GetChildren() do
if Child:IsA("ParticleEmitter") then
CalculateParticleEmitterBounds(Child, Obj)
end
end
elseif Obj:IsA("Attachment") then
for _, Child in Obj:GetChildren() do
if Child:IsA("ParticleEmitter") then
CalculateParticleEmitterBounds(Child, Obj)
end
end
end
end
if MinX == math.huge then
return Vector3.zero, Orientation
end
local Omin, Omax = Vector3.new(MinX, MinY, MinZ), Vector3.new(MaxX, MaxY, MaxZ)
local Size = Omax - Omin
local CenterCFrame = Orientation - Orientation.Position + Orientation:PointToWorldSpace((Omax + Omin) / 2)
if Gizmo then
Gizmo:DrawBox(CenterCFrame, Size, 5, Color3.fromRGB(255, 255, 0))
end
return Size, CenterCFrame
end
return GetBoundingBox
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment