Making a Super Mario 64 Painting Warp

Hi, I’m Phantomazing. I script, I model, I design, whatever whatever. I wanted to do a full explanation on this Super Mario 64 inspired painting warp I’ve shown across social media.

Broke it into three parts: the mesh of the painting itself, the code that makes the absolute bare minimum of the painting, and code that makes it look much nicer.

The Mesh

Yes, this was done with tweening a mesh. This is basically just a very thin cube (not a plane) with subdivisions, and the center two vertices pulled back via soft select in Maya. Proportional Editing in Blender can do the same thing; in fact, both programs use the same hotkey (b) to activate it.

There is something extra I did, mind you. Something you can hardly see at all. I mirrored the mesh and deleted the mirrored part, save for four faces at the furthest point. And shrunk them to negligible size relative to the furthest point. And turned on back-face culling. I trimmed the quads into triangles to take up even less space but that’s just some unnecessary optimization.

Why I did such a thing is this: when I tween the scale of the mesh, I want the plane part to be at the “center” of the whole model, where scaled meshes get squished to. The top gif is with the tiny extra faces; bottom is without. Both are SpecialMeshes with this script and a SelectionBox as its sibling. Sorry the gifs couldn’t be perfect loops.

local ts = game:GetService("TweenService")
local ti = TweenInfo.new(1, Enum.EasingStyle.Quad, Enum.EasingDirection.Out, -1, true)
local t = ts:Create(script.Parent.Mesh, ti, {Scale = Vector3.new(1, 1, 0)}):Play()

H8qeFW3aKd
j3KWkBVQc4

For UVs, since it’s just a cube with subdivisions, the whole UV map is just the six faces of the cube overlapping eachother. Put whatever texture you want and it’ll display it like a decal on each face. End of story. You can use the mesh I made that’s 1x1x1 (1x1x2, if you count the empty space), or you can make one yourself.

The Basic Code

It’s the code!

The Code Itself
-- Made by Phantomazing
-- This is a LocalScript in StarterPlayerScripts

-- Services
local Players = game:GetService("Players")
local TweenService = game:GetService("TweenService")

-- Stuff like the player and the camera etc etc
local Camera = workspace.CurrentCamera
local Player = Players.LocalPlayer
local Character = Player.Character or Player.CharacterAdded:Wait()
local Root = Character:WaitForChild("HumanoidRootPart")

-- Stuff in the workspace
local Room = workspace:WaitForChild("Mario64Painting")
local Painting = Room:WaitForChild("Painting")
local Mesh = Painting:WaitForChild("Mesh")
local Sound = workspace:WaitForChild("Sound")
local Destination = workspace.Spawn.SpawnLocation

-- TweenInfos
local TIOut = TweenInfo.new(0.1, Enum.EasingStyle.Quad)
local TIIn = TweenInfo.new(0.9, Enum.EasingStyle.Elastic)

-- Debounce
local inPainting = false

-- If the player dies we have to update with new character model information
local function Refresh(character)
	Character = character
	Root = character:WaitForChild("HumanoidRootPart")
end

-- Jump in!
local function JumpIn(obj)
	-- make sure it's the player, and make sure this sequence isn't running already
	if obj.Parent ~= Character or inPainting then return end
	inPainting = true
	
    -- The tween that moves the Player's Root's CFrame to behind the painting
	local offset = Vector3.new(-8, 0, 0)
	local newCFrame = CFrame.new(Painting.Position + offset, Root.CFrame.LookVector)
	local tweenPos = TweenService:Create(Root, TweenInfo.new(), {["CFrame"] = newCFrame})
	
    -- The two tweens that animate the painting
	local tweenOut = TweenService:Create(Mesh, TIOut, {Scale = Vector3.new(1, 1, 3)})
	local tweenIn = TweenService:Create(Mesh, TIIn, {Scale = Vector3.new(1, 1, 0)})
	
    -- Fix the camera; don't let gravity mess things up too
	Camera.CameraType = Enum.CameraType.Fixed
	Root.Anchored = true
	Sound:Play()
	
    -- Animation sequence!
	tweenPos:Play()
	tweenOut:Play()
	tweenOut.Completed:Wait()
	tweenIn:Play()
	tweenIn.Completed:Wait()
	wait(1)
	
    -- Move the player to the place you want them to go
	Root.CFrame = Destination.CFrame
	Root.Anchored = false
	Camera.CameraType = Enum.CameraType.Custom
	
	inPainting = false
end

-- Listeners
Painting.Touched:Connect(JumpIn)
Player.CharacterAdded:Connect(Refresh)

In one paragraph here’s a summary of the code:

When the player touches the painting, the player gets sucked into the painting and the camera stays behind. Two tweens play back to back that stretch the mesh inward, and sling it back into place like a slingshot. After the animation sequence the player gets put into the new spot. The code also refreshes if the player dies.

With that “slingshot-style” animation I was talking about, like in animation in general you should exaggerate it. Don’t go subtle because the animation goes fast.

I also wanna have a little aside about tweening CFrames. While this fix has served me well, I learned the other day I could’ve just done:
local tweenPos = TweenService:Create(Root, TweenInfo.new(), {["CFrame"] = newCFrame})
this whole time.

["Property"] works with any property in case the property shares the name with a data type or whatever. I learned this from someone who worked with Lua but not on Roblox. Thanks tex.

Fancy Things and Fixes

Let’s Shrink the Player!
That’s what I did in the video I posted. It hides the player and makes it look more like you’re Blue-skidooing into the painting. Put this block of code into the declarations part of the script, and in the Refresh() function too but get rid of the locals:

-- Humanoid scale values
local Humanoid = Character:WaitForChild("Humanoid")
local Sizes = {
    Humanoid:WaitForChild("HeadScale"),
    Humanoid:WaitForChild("BodyWidthScale"),
    Humanoid:WaitForChild("BodyHeightScale"),
    Humanoid:WaitForChild("BodyDepthScale"),
}

And over in the body of the JumpInFunction, just before the animation sequence, put this block of code in. It plays a series of tweens that will shrink the player down into nothingness, and bring them back to normal just after the painting animation plays. You will want to save the player’s original sizes mind you; that’s what ogSizes is for.

-- Shrink the player!
local TIShrink = TweenInfo.new(0.5, Enum.EasingStyle.Quad)
local ogSizes = {}
for _, size in ipairs(Sizes) do
	ogSizes[size.Name] = size.Value
	local tween = TweenService:Create(size, TIShrink, {Value = 0})
    local tweenInDone
	tweenInDone = tweenIn.Completed:Connect(function()
            wait(1)
		size.Value = ogSizes[size.Name]
            tweenInDone:Disconnect()
	end)
	tween:Play()
end

For the sake of demonstration, the painting is invisible. Also, anywhere in the code that has wait(1), feel free to change that into a consistent timer variable because I forgot to. You probably should.

V9LWEYLgRJ

Why am I in the floor?

Yeah, you’ll need to add the character’s HipHeight to your destination. Find Root.CFrame = Destination.CFrame and do this:

local HipHeight = Vector3.new(0, Humanoid.HipHeight, 0)
Root.CFrame = Destination.CFrame + HipHeight

I’m still in the floor!

Yeah it’ll happen if your destination is like, a very thick part. I’d advise making a new part that’s thin, transparent, can’t collide, anchored, and and halfway into the ground.

My mesh blinks and looks gross!

This happened to me with my first go at it. It can happen if your mesh goes into negative scale because of the Elastic tween. It didn’t happen when I made a new mesh and map for this tutorial, but it could still happen. This is what I did. In the body of the code, overwriting the tweenOut and tweenIn declaration lines.

local Vector3Value = Instance.new("Vector3Value")
Vector3Value.Value = Mesh.Scale
local origOrientation = Painting.Orientation

local tweenOut = TweenService:Create(Vector3Value, TIOut, {Value = Vector3.new(8, 8, 20)})
local tweenIn = TweenService:Create(Vector3Value, TIIn, {Value = Vector3.new(8, 8, 0)})
	
local changed = Vector3Value.Changed:Connect(function()
	local overshot = Vector3Value.Value.Z < 0
	Painting.Orientation = origOrientation + Vector3.new(0, (overshot and 180 or 0), 0)
	Mesh.Scale = Vector3Value.Value * Vector3.new(1, 1, (overshot and -1 or 1))
end

and at the end of the function, just before inPainting = false at the very end:
changed:Disconnect()

What this block of code does is prevent the mesh scale from going negative. If it tries to do so, it makes it positive and rotate the mesh 180 degrees so you get the back side.
It accomplishes this by making a new Vector3Value and tweening its Value instead of the mesh scale directly. I connected that logic to the Vector3Value’s changed event, and disconnected it at the very end.

It sucks me in if I just simply brush against it!

That’s how the Touched event works, but that doesn’t happen to Mario in Mario 64 when he brushes against a painting now does it? My solution is this: Make a new invisible part called “HitBox” and stick it a bit inwards into the painting warp. Make sure you define it at the start of your code. Turn off the painting’s collision. Let the HitBox handle the Touched event instead of the painting itself.

local HitBox = workspace:WaitForChild("HitBox")
-- the rest of the script goes here lololol
HitBox.Touched:Connect(JumpIn) -- NOT Painting.Touched. Do HitBox.Touched instead.

I can see behind the painting by moving my camera!

Do the hitbox thingy above, but make the part NOT TRANSPARENT and instead give it a BlockMesh with 0, 0, 0 scale. Make it CanCollide too. There’s probably a better solution out there but I’m lazy zzz

If you don’t have time to read and/or assembling the pieces, and instead like to play with it yourself, here’s a place with just the painting and nothing else. Code’s in PlayerStarterScripts. Lemme know if something’s confusing, or broken, or can be expanded upon, etc etc

Have a good one!

Painting.rbxl (22.8 KB)

95 Likes

Amazing stuff and very clever use of meshes!

8 Likes

That looks pretty nice tbh, a very clever use of meshes as said above.

oh my god, i actually love this so much!! Great job making it.