“Superliminal” features absolutely mind-bending ways of interacting with your enviroment, one allowing you to resize and reposition parts using only your mouse and your camera, as seen in the first scene of this video:
I wanted to recreate this effect in Roblox, so follow along through my (suprisingly quick) adventure of scripting. Our final product can be used like this:
First attempt
How about we just write down some code we have in our heads on how to recreate this effect in the simplest way possible? Obviously we need to do two things:
- Figure out a rough estimate of where the part should be, and
- Resize the part in accordance to how far we want to move it.
We’re going to use the following script as a skeleton for the required maths (all of it is in a single local script in StarterCharacterScripts
). We will also create a remote event in ReplicatedStorage
(named UpdPhys) to transfer network ownership of the part to the client moving it, to make it correctly simulate physics for the client; and a script in ServerScriptService
. This concept was meant to be single player only, so this was the easiest solution for ownership issues; figure out something else if you plan on using this in a multiplayer environment (especially since this version can cause security issues in multiplayer).
The base
The server script will just have these lines:
game:GetService("ReplicatedStorage").UpdPhys.OnServerEvent:Connect(function(p, obj)
obj:SetNetworkOwner(p)
end)
The local script with some globals and all events we need:
local p = game:GetService("Players").LocalPlayer
local m = p:GetMouse()
local cam = workspace.CurrentCamera
local selObj = nil
local function canMove(ob)
return ob.Name == "Object"
end
local e = game:GetService("ReplicatedStorage").UpdPhys
m.Button1Down:Connect(function()
if m.Target and canMove(m.Target) then
end
end)
m.Button1Up:Connect(function()
if selObj then
end
selObj = nil
end)
game:GetService("RunService").RenderStepped:Connect(function(dt)
if selObj then
end
end)
We will be using the function canMove
to check if a part is a physics object that should be draggable. In this example you just need to set their name to “Object”, but modify your canMove
function how you want.
Alright, we have a simple framework set up. Let’s begin by letting the user click and drag parts. We’re adding these lines to inside our target check inside the Button1Down
event:
m.Target.CanCollide = false
e:FireServer(m.Target)
m.Target.Anchored = true
m.Target.Velocity = Vector3.new()
m.Target.RotVelocity = Vector3.new()
selObj = m.Target
To avoid having glitchy physics artifacts and weird player interactions we anchor the parts and set them completely static (remember that a part with velocity will still act as a “conveyor” when anchored). We’re also setting the selObj
(selected Object) global variable to the part, so we can reposition it later.
Conversely, we’re adding this inside our Button1Up
check:
selObj.Anchored = false
selObj.CanCollide = true
Parts should continue obeying physics again when they are let go. We don’t need to update the velocity again because an anchored part can not gain any velocity.
My first incentive with a camera-based system was to simply remember where the part was in relation to the camera and continually reposition the part there. This “relation” can easily be calculated with CFrame:ToObjectSpace
. To “remember” where the part was in front of the camera, we just create another global variable.
--Early in the script (optional)
local camCframe = nil
--In the Button1Down check
camCframe = cam.CFrame:ToObjectSpace(m.Target.CFrame)
--In our updater (RenderStepped event)
selObj.CFrame = cam.CFrame:ToWorldSpace(camCframe)
Explaining object space and this simple CFrame manipulation technique isn’t going to be in this tutorial, the Wiki explains it pretty well.
With only these three lines we can already replicate the visual effect of picking something up.
We are immidiately disappointed at the realization that we are in fact just picking up a part and it doesn’t resize in a cool way. But just resizing the part doesn’t fix the entire issue, as we would just drop a large part right infront of ourselves again.
Instead, the solution (and the entire algorithm behind the end product) is to figure out the “furthest possible position” for the part to be while still having the illusion of being in the same location (so we resize it as much as we move it away - luckily, this relation is exactly linear, I’ll explain this later) and of course not clipping through walls (also important later).
To figure out the furthest possible position without intersection, we just cast a ray and see where it stops. In our RenderStepped
loop:
local ray = Ray.new(cam.CFrame.p, cam.CFrame.LookVector * 1000)
--Alternatively: Ray.new(m.UnitRay.Origin, m.UnitRay.Direction * 1000), the same ray is meant
local _, p, n = workspace:FindPartOnRayWithIgnoreList(ray, {script.Parent, selObj})
selObj.Position = p
We get three results, the hit part (which we’re actually going to completely ignore), the closest point possible (the end of the ray or the first intersection), and a surface normal at the point of intersection (we need this later). Note that we’re ignoring our own character (script.Parent
) and the object itself, as they should never influence the end position. Lastly, we size the part. Through trial and error I found a very rough estimation formula (that we will replace later) which calculates a size through the distance from the camera:
--Global variables (optional)
local oS = nil --"Original size"
--Button1Down
oS = m.Target.Size
--RenderStepped
selObj.Size = selObj.Size = oS * (cam.CFrame.p-p).magnitude / 40
Proper distance algorithm
…Good enough, I guess? We can’t easily create a formula for the size, so let’s try something else.
Let’s rewrite our position algorithm so that we can control the distance of the object through a single number variable (called dist)
:
--Let the distance be the physical distance between the end point and our camera
local dist = (cam.CFrame.p - p).magnitude
--The position is then our camera position offset by the camera's look vector
--times the distance in studs. The look vector is a unit vector so the offset
--is a vector of length "dist".
selObj.Position = cam.CFrame.p + cam.CFrame.LookVector * dist
--The relative size is then just the displacement from the camera "compared"
--to the original displacement when we picked it up. Multiply it by the old size.
selObj.Size = oS * (dist / camCframe.p.magnitude)
The results look very promising! The size is finally correctly calculated and the basic concept is finally looking like footage from the game. The most obvious issue is the part clipping inside nearby parts. We counteract this by moving the part “back” its size. Since both position and size are now set by a single variable, we just have to recalculate that variable. Change this before the position/size logic:
--The size halved is the absolute distance we have to remove.
--This is the simplest method of calculating this distance, but it works.
local dist = (cam.CFrame.p - p).magnitude - selObj.Size.magnitude / 2
Angle correction
This solves a majority of clipping issues, but two more edge cases are demonstrated in the following video.
- When we move around our target point, the part clips into the wall.
- When the part is larger than the distance between the mouse and a corner, the part clips into the corner.
I solved issue 1 simply through trial and error. Since the clipping amount is dependant on the angle between ourselves and the wall, let’s write a function to calculate the angle between two vectors:
local function angleBetween(v1, v2)
return math.acos(v1:Dot(v2)/(v1.magnitude * v2.magnitude))
end
Through some trial and error (printing the angle between the normal vector n
and the camera vector ray.Direction
) I figured out this simple expression we add to our distance variable:
local dist = (...) * -math.cos(angleBetween(ray.Direction, n))
This was done through experimenting, perhaps I just had a lucky hit, either way, it works, so I didn’t write up an explanation for this part. Sorry! Here’s the result:
Region clipping
This fixes the clipping issues at hard angles around walls, great. The last issue we need to address is the clipping in corners. My thought process was simple: If anything is “inside” our part, keep moving it back until nothing is inside it any more. For this, I used EgoMoose’s rotated Region3 module.
--Globally
local rr = require(script.RotatedRegion3)
--RenderStepped
for i = 1, 0, -0.01 do
local R = rr.fromPart(selObj)
if #R:cast({script.Parent, selObj}, 1) > 0 then
dist = dist * i
selObj.Size = (oS * dist / camCframe.p.magnitude)
selObj.Position = cam.CFrame.p + cam.CFrame.LookVector * dist
else
break
end
end
We simply shorten the distance until no other parts are intersected (except the part itself and our character). Theoretically this would also solve the problems with part size and angles, but the module can be very imprecise at tiny increments and the issues become apparent again, so we keep our previous solutions.
Tween part center to mouse
The game logic is now perfect! One last thing I didn’t like is the instant teleportation of parts when you pick them up. After messing with CFrames long enough I decided the simplest solution is just to make the part tween in the center of the screen quickly, as that’s what’s done in the game anyway.
--Globally (mandatory!)
local coolnessOffset = Vector3.new()
--Returns the closest object space point on a ray "r" (closest to "p")
local function getCP(r, p)
local rO, D = r.Origin - p, r.Direction
return rO - ( rO:Dot(D)/D:Dot(D) ) * D
end
--Button1Down
coolnessOffset = -getCP(m.UnitRay, m.Target.Position) --When we pick up the part, set the offset from the center of the screen according to the center of the object before it was picked up.
--Create a new RenderStepped loop before our main loop, and add this:
--Continually "reduce" the offset. This acts as a smooth quadratic tween.
coolnessOffset = coolnessOffset:Lerp(Vector3.new(), 0.2)
--Main RenderStepped loop
selObj.Position = selObj.Position + coolnessOffset
Final result
Our final product should behave like this:
Awesome, we have fully replicated this game mechanic! There’s some minor issues and bugs I encourage you to find and fix yourself, I feel like any more bug fixing would bloat the script beyond a tutorial. The script checks out at just 74 lines and the entire thing took (apart from this tutorial) took me about an hour.
Your final script could look something like this: https://hastebin.com/wivubofayo.sql
Edit: The link to the script expired, but the whole script is still in the demo place if you want to take a look.
A demo place is attached: superliminal.rbxl (31.4 KB) all scripts are included.
Feel free to use everything as you wish. Have fun!
EDIT:
I have edited the scripts slightly and added an event and made a multiplayer version of this. Here’s a seperate place download: superliminal.rbxl (31.8 KB)
The other scripts and downloads in this post have not been updated, they will remain singleplayer only.