Today's goal will be to obtain a basic functioning understanding of Three.js by completing a coding challenge. The challenge is to create a forced perspective illusion similar to the game Superliminal. In Superliminal, the player can pick up objects and resize them by moving them closer or further away using the player camera. This creates the illusion that the object is either larger/smaller or further/closer than it actually is. The player can then use these objects to solve puzzles and progress through the game.
🔗 Markiplier Plays SuperliminalSince our primary goal is to learn enough Three.js to form a functional understanding, we will not be focusing on creating a full-fledged game (If making a Triple-A game from scratch is what you were going for, Three.js may not be what you were looking for anyway). Instead, we will create a simple scene with a single object that we can resize by moving it closer or further away, creating the visual illusion that we desire. We will also add a wall to the scene to give us a reference point to compare the object's size. Here is a simple functioning demo of what we hope to achieve.
Instructions:
Let's begin coding. We will first bootstrap our Three.js project by following the official 🔗 Installation Guide from Three.js. Three.js offers a few ways to get started, but we will be using the npm
method (Option 1). Using the CDN method with plain HTML is also viable, but we will not be using it in this tutorial, due to the undeniable conveniences that Vite
and npm
puts on the table. This means that you'll need Node.js and NPM installed.
1. Create a new directory for your project and navigate into it. Then, create these two files:
@@ -0,0 +1,15 @@
|
|
1
|
+
<!DOCTYPE html>
|
2
|
+
<html lang="en">
|
3
|
+
<head>
|
4
|
+
<meta charset="utf-8" />
|
5
|
+
<title>My first three.js app</title>
|
6
|
+
<style>
|
7
|
+
body {
|
8
|
+
margin: 0;
|
9
|
+
}
|
10
|
+
</style>
|
11
|
+
</head>
|
12
|
+
<body>
|
13
|
+
<script type="module" src="/main.js"></script>
|
14
|
+
</body>
|
15
|
+
</html>
|
@@ -0,0 +1 @@
|
|
1
|
+
import * as THREE from "three";
|
2. Install Three.js and Vite using npm:
# three.js
npm install --save three
# vite
npm install --save-dev vite
# To start the development server
npx vite
# To build the project (optional, run after project is done)
# npx vite build
# npx serve dist
.
├── index.html
├── main.js
├── package-lock.json
└── package.json
1 directory, 4 files (excluding node_modules)
3. Creating the Scene
We will now create a basic scene with the ball and the wall. The code below is just a modified version of the boilerplate detailed in 🔗 Three.js > Creating a Scene. We've modified the boilerplate starter code to include the ball (SphereGeometry
) and wall (PlaneGeometry
) objects.
@@ -1 +1,41 @@
|
|
1
1
|
import * as THREE from "three";
|
2
|
+
|
3
|
+
// three.js objects (https://threejs.org/docs/#manual/en/introduction/Creating-a-scene)
|
4
|
+
const scene = new THREE.Scene();
|
5
|
+
const camera = new THREE.PerspectiveCamera(
|
6
|
+
75,
|
7
|
+
window.innerWidth / window.innerHeight,
|
8
|
+
0.1,
|
9
|
+
1000
|
10
|
+
);
|
11
|
+
const renderer = new THREE.WebGLRenderer();
|
12
|
+
const plane = new THREE.Mesh(
|
13
|
+
new THREE.PlaneGeometry(5, 5),
|
14
|
+
new THREE.MeshBasicMaterial({ color: 0xffff00, side: THREE.DoubleSide })
|
15
|
+
);
|
16
|
+
const sphere = new THREE.Mesh(
|
17
|
+
new THREE.SphereGeometry(1),
|
18
|
+
new THREE.MeshBasicMaterial({ color: 0xff0000 })
|
19
|
+
);
|
20
|
+
|
21
|
+
// setup function: we call this function only once at the beginning of the program to setup the scene
|
22
|
+
function setup() {
|
23
|
+
renderer.setSize(window.innerWidth, window.innerHeight);
|
24
|
+
|
25
|
+
document.body.appendChild(renderer.domElement);
|
26
|
+
|
27
|
+
// add objects to the scene
|
28
|
+
scene.add(plane);
|
29
|
+
scene.add(sphere);
|
30
|
+
|
31
|
+
// move camera backwards so we can see the objects
|
32
|
+
camera.position.z = 5;
|
33
|
+
}
|
34
|
+
|
35
|
+
// define the animation loop function which will run ones per frame (typically 60fps)
|
36
|
+
function animate() {
|
37
|
+
renderer.render(scene, camera);
|
38
|
+
}
|
39
|
+
|
40
|
+
setup(); // setup the scene
|
41
|
+
renderer.setAnimationLoop(animate); // start the animation loop
|
You should now see this in your browser.
Next, let's add 🔗 Pointer Lock Controls to our scene. This will allow us to look around by moving our mouse cursor.
@@ -1,4 +1,5 @@
|
|
1
1
|
import * as THREE from "three";
|
2
|
+
import { PointerLockControls } from "three/addons/controls/PointerLockControls";
|
2
3
|
|
3
4
|
// three.js objects (https://threejs.org/docs/#manual/en/introduction/Creating-a-scene)
|
4
5
|
const scene = new THREE.Scene();
|
@@ -17,6 +18,7 @@ const sphere = new THREE.Mesh(
|
|
17
18
|
new THREE.SphereGeometry(1),
|
18
19
|
new THREE.MeshBasicMaterial({ color: 0xff0000 })
|
19
20
|
);
|
21
|
+
const pointerLockControls = new PointerLockControls(camera, document.body);
|
20
22
|
|
21
23
|
// setup function: we call this function only once at the beginning of the program to setup the scene
|
22
24
|
function setup() {
|
@@ -24,6 +26,8 @@ function setup() {
|
|
24
26
|
|
25
27
|
document.body.appendChild(renderer.domElement);
|
26
28
|
|
29
|
+
document.body.addEventListener("click", (e) => pointerLockControls.lock());
|
30
|
+
|
27
31
|
// add objects to the scene
|
28
32
|
scene.add(plane);
|
29
33
|
scene.add(sphere);
|
Great! Now, I want you to go to the browser and click on the screen. You should now be able to control the camera by moving your mouse around! 🎉 Next, let us work on the player movement. We will add the ability to move the player forward, backward, left, and right using the 'W', 'A', 'S', and 'D' keys, respectively.
First, we create state
to keep track of which keys are currently being pressed.
@@ -20,6 +20,16 @@ const sphere = new THREE.Mesh(
|
|
20
20
|
);
|
21
21
|
const pointerLockControls = new PointerLockControls(camera, document.body);
|
22
22
|
|
23
|
+
// state variables
|
24
|
+
const state = {
|
25
|
+
keyboard: {
|
26
|
+
forward: false,
|
27
|
+
backward: false,
|
28
|
+
left: false,
|
29
|
+
right: false,
|
30
|
+
},
|
31
|
+
};
|
32
|
+
|
23
33
|
// setup function: we call this function only once at the beginning of the program to setup the scene
|
24
34
|
function setup() {
|
25
35
|
renderer.setSize(window.innerWidth, window.innerHeight);
|
The reason why I chose to store these states as properties of an object instead of just having them as individual variables extends beyond just organization and readibility. I did this with foresight, as I anticipate that we will be writing code in other files that will need to access these states. By storing them in an object, we can pass this object to other functions and files, and they will be able to modify the states as well. This is a concept known as pass by reference. Check out this article for more information: 🔗 Pass By Reference.
Tldr, we will use this mechanism to modify the state properties from inside functions of many different files, and the changes will reflect in every file.
Next, we will create a new file called camera.js
in the same folder as main.js
:
@@ -0,0 +1,49 @@
|
|
1
|
+
import * as THREE from "three";
|
2
|
+
|
3
|
+
// constants
|
4
|
+
const SPEED = 0.05;
|
5
|
+
|
6
|
+
export function updateCameraPosition(camera, state) {
|
7
|
+
const cameraDir = new THREE.Vector3();
|
8
|
+
|
9
|
+
/* Another instance of pass-by-reference: Object3D.getWorldDirection() will copy
|
10
|
+
the value of the Object's world direction into the Vector3 object we pass in. */
|
11
|
+
camera.getWorldDirection(cameraDir);
|
12
|
+
|
13
|
+
/* Clone the vector so it doesn't affect the original. */
|
14
|
+
const cameraDirWithoutY = cameraDir.clone();
|
15
|
+
|
16
|
+
/* We are getting rid of the 'y' component. We don't want to move up and down when the
|
17
|
+
camera direction is even slightly inclined. This simulates walking on 'flat ground'.
|
18
|
+
Also, this is known as 'projection'. */
|
19
|
+
cameraDirWithoutY.y = 0;
|
20
|
+
|
21
|
+
/**
|
22
|
+
* After the projection, the vector is no longer a unit vector. (think Pythagorean theorem).
|
23
|
+
* We normalize it to make it a unit vector again, in order to have consistent movement speed.
|
24
|
+
*/
|
25
|
+
cameraDirWithoutY.normalize();
|
26
|
+
|
27
|
+
const cameraDirLeft = new THREE.Vector3();
|
28
|
+
|
29
|
+
/* The Cross Product gives us the vector which represents the left direction of the camera.
|
30
|
+
Continue reading for a more detailed explanation. */
|
31
|
+
cameraDirLeft.crossVectors(camera.up, cameraDir);
|
32
|
+
|
33
|
+
if (state.keyboard.forward) {
|
34
|
+
// 'forward' means to move toward the direction that the camera is facing
|
35
|
+
// this is equiv to saying `cameraPos = cameraPos + cameraDir * SPEED`
|
36
|
+
camera.position.addScaledVector(cameraDirWithoutY, SPEED);
|
37
|
+
}
|
38
|
+
if (state.keyboard.backward) {
|
39
|
+
camera.position.addScaledVector(cameraDirWithoutY, -SPEED);
|
40
|
+
}
|
41
|
+
if (state.keyboard.left) {
|
42
|
+
camera.position.addScaledVector(cameraDirLeft, SPEED);
|
43
|
+
}
|
44
|
+
if (state.keyboard.right) {
|
45
|
+
camera.position.addScaledVector(cameraDirLeft, -SPEED);
|
46
|
+
}
|
47
|
+
|
48
|
+
return cameraDir;
|
49
|
+
}
|
Remember when I was talking about pass by reference? This is where it comes into play. We are passing the camera
and state
objects to the updateCameraPosition
function. This function will then modify the camera
object's position based on the state
object's properties, and it all works even if the code were refactored into multiple different files. This is a very common pattern in programming, and it is a good practice to follow. It makes your code more modular and easier to maintain. This way, we can refactor the code that handles the camera movement logic to its own file, and not blow up main.js
with too much code.
Also, I just want to note the use of the Cross Product to compute the normal of the plane which is formed by the camera's forward and up direction. (The 'normal' is the vector which is 90° to the two vectors that form the plane). Here is a quick illustration:
Just in case that wasn't enough, here is 🔗 Mechanics Map (Open Textbook) > Cross Product, that's where I got the original diagram from.
Lastly, notice that I return cameraDir
on the last line of this function. That's just a convenient way to get the camera's direction vector so that we can reuse it in other parts of our code.
Great! Now we have the logic to update the camera's position based on the state
object. We'll just need to create the keydown
and keyup
event listeners to update the state
object itself. Create a new file called eventListeners.js
:
@@ -0,0 +1,33 @@
|
|
1
|
+
export function onKeyDown(event, state) {
|
2
|
+
switch (event.key) {
|
3
|
+
case "w":
|
4
|
+
state.keyboard.forward = true;
|
5
|
+
break;
|
6
|
+
case "s":
|
7
|
+
state.keyboard.backward = true;
|
8
|
+
break;
|
9
|
+
case "a":
|
10
|
+
state.keyboard.left = true;
|
11
|
+
break;
|
12
|
+
case "d":
|
13
|
+
state.keyboard.right = true;
|
14
|
+
break;
|
15
|
+
}
|
16
|
+
}
|
17
|
+
|
18
|
+
export function onKeyUp(event, state) {
|
19
|
+
switch (event.key) {
|
20
|
+
case "w":
|
21
|
+
state.keyboard.forward = false;
|
22
|
+
break;
|
23
|
+
case "s":
|
24
|
+
state.keyboard.backward = false;
|
25
|
+
break;
|
26
|
+
case "a":
|
27
|
+
state.keyboard.left = false;
|
28
|
+
break;
|
29
|
+
case "d":
|
30
|
+
state.keyboard.right = false;
|
31
|
+
break;
|
32
|
+
}
|
33
|
+
}
|
@@ -1,5 +1,7 @@
|
|
1
1
|
import * as THREE from "three";
|
2
2
|
import { PointerLockControls } from "three/addons/controls/PointerLockControls";
|
3
|
+
import { onKeyDown, onKeyUp } from "./eventListeners";
|
4
|
+
import { updateCameraPosition } from "./camera";
|
3
5
|
|
4
6
|
// three.js objects (https://threejs.org/docs/#manual/en/introduction/Creating-a-scene)
|
5
7
|
const scene = new THREE.Scene();
|
@@ -37,6 +39,8 @@ function setup() {
|
|
37
39
|
document.body.appendChild(renderer.domElement);
|
38
40
|
|
39
41
|
document.body.addEventListener("click", (e) => pointerLockControls.lock());
|
42
|
+
document.body.addEventListener("keydown", (e) => onKeyDown(e, state));
|
43
|
+
document.body.addEventListener("keyup", (e) => onKeyUp(e, state));
|
40
44
|
|
41
45
|
// add objects to the scene
|
42
46
|
scene.add(plane);
|
@@ -48,6 +52,8 @@ function setup() {
|
|
48
52
|
|
49
53
|
// define the animation loop function which will run ones per frame (typically 60fps)
|
50
54
|
function animate() {
|
55
|
+
const cameraDir = updateCameraPosition(camera, state);
|
56
|
+
|
51
57
|
renderer.render(scene, camera);
|
52
58
|
}
|
53
59
|
|
You will now be able to move your player around using the 'WASD' keys! Finally, we'll tweak the index.html
file to add a crosshair to the center of the screen:
@@ -7,9 +7,22 @@
|
|
7
7
|
body {
|
8
8
|
margin: 0;
|
9
9
|
}
|
10
|
+
|
11
|
+
#crosshair {
|
12
|
+
position: absolute;
|
13
|
+
width: 15px;
|
14
|
+
height: 15px;
|
15
|
+
left: 50%;
|
16
|
+
top: 50%;
|
17
|
+
transform: translate(-50%, -50%);
|
18
|
+
border: 3px solid #fff;
|
19
|
+
border-radius: 50%;
|
20
|
+
pointer-events: none;
|
21
|
+
}
|
10
22
|
</style>
|
11
23
|
</head>
|
12
24
|
<body>
|
25
|
+
<div id="crosshair"></div>
|
13
26
|
<script type="module" src="/main.js"></script>
|
14
27
|
</body>
|
15
28
|
</html>
|
Try moving your player around now using the 'WASD' keys! (You may need to click on the screen to lock the cursor first)
Next, we will work on the ability to pick up the object by holding down the left mouse button. In order to interact the object, we first have to determine when the object lies under our cursor crosshair. To do this, we will make use of 🔗 THREE.Raycaster()
.
Shameless self-plug to another relevant tutorial article: 🔗 Raycasting to cover the front or back surface of an object
We will shoot a ray out from our camera into the world, and detect if the ray intersects with our target object (the ball). If it does, we will bring the object closer within a certain hard-coded distance to our camera (say, 1 unit, for example). However, we must take care to translate/move the object towards our camera in a careful fashion, by taking into account the exact point where our ray first intersects the object. We will call this point the "pivot/handle". In the diagram below, this pivot point is highlighted by a red dot on the surface of the ball object.
Observe that we cannot simply 'teleport' our object closer to the camera by setting the object.position
, because that updates the object's center position directly. Imagine grabbing a hold of the object's top right corner, and having the object snap abruptly to the center of your crosshair. That would indeed be quite a jarring experience.
We would instead have to first anchor our object to the red pivot/handle point, and then move the red pivot point to our target location. Then, the object will 'follow' the pivot point to the target location smoothly, preserving the relative distance between the crosshair selection and the center of the object.
In other words, we will create a virtual and invisible pivot point object. Then, we'll 'attach' the ball object to the pivot point object as a child. We will manipulate the position of the pivot point object (parent) and the ball object (child) will inherit the change. This way, we avoid directly modifying the position of the ball (we instead modify it through the parent 'handle' object). If you're not familiar with how Parent/Child relationships work in three.js, I strongly recommend reading 🔗 SBCode.net > Object3D Hierarchy (it even contains an interactive demo to illustrate how changing a parent's position will affect the descendants!).
@@ -0,0 +1,50 @@
|
|
1
|
+
import * as THREE from "three";
|
2
|
+
|
3
|
+
export class PivotPoint {
|
4
|
+
constructor(scene, targetObj, camera) {
|
5
|
+
this.scene = scene;
|
6
|
+
this.targetObj = targetObj;
|
7
|
+
this.camera = camera;
|
8
|
+
|
9
|
+
this.obj = new THREE.Object3D();
|
10
|
+
scene.add(this.obj);
|
11
|
+
}
|
12
|
+
|
13
|
+
detach() {
|
14
|
+
/**
|
15
|
+
* Attaching an object to another new object in Three.js will detach it from its current parent.
|
16
|
+
*/
|
17
|
+
this.scene.attach(this.targetObj);
|
18
|
+
}
|
19
|
+
|
20
|
+
anchor(from) {
|
21
|
+
/*
|
22
|
+
* Detach the sphere from the pivotParent by attaching it to the scene (using `.attach` instead of `.add` to preserve world transform)
|
23
|
+
* Recommmendation: Experiment with and/or study the difference between `.add` and `.attach` in Three.js
|
24
|
+
* In other words: we are preparing to move the pivotParent to the target start location, while keeping the object in place.
|
25
|
+
*/
|
26
|
+
this.detach();
|
27
|
+
|
28
|
+
/*
|
29
|
+
* We move the pivotParent object to the target start location.
|
30
|
+
* Because the object is now attached to the scene, it will not move along with the pivotParent, but instead remain in place.
|
31
|
+
*/
|
32
|
+
this.obj.position.copy(from);
|
33
|
+
|
34
|
+
/**
|
35
|
+
* Now that we've moved our pivotParent to the target anchor point, we can reattach the object to the pivotParent.
|
36
|
+
* Any future movement of the pivotParent will now affect the object as well.
|
37
|
+
*/
|
38
|
+
this.obj.attach(this.targetObj);
|
39
|
+
}
|
40
|
+
|
41
|
+
setPos(to) {
|
42
|
+
/**
|
43
|
+
* We make changes to the pivotParent's position, and the object will follow along.
|
44
|
+
* Example: Moving the pivotParent 5 units backwards will result in the object moving 5 units backwards as well.
|
45
|
+
* Crucially, the relative transforms will be preserved, meaning that the object will not be centered at the target location,
|
46
|
+
* but instead move 5 units backwards relative from its current position.
|
47
|
+
*/
|
48
|
+
this.obj.position.copy(to);
|
49
|
+
}
|
50
|
+
}
|
@@ -31,3 +31,11 @@ export function onKeyUp(event, state) {
|
|
31
31
|
break;
|
32
32
|
}
|
33
33
|
}
|
34
|
+
|
35
|
+
export function onMouseDown(state) {
|
36
|
+
state.mouse.isMouseDown = true;
|
37
|
+
}
|
38
|
+
|
39
|
+
export function onMouseUp(state) {
|
40
|
+
state.mouse.isMouseDown = false;
|
41
|
+
}
|
@@ -1,7 +1,8 @@
|
|
1
1
|
import * as THREE from "three";
|
2
2
|
import { PointerLockControls } from "three/addons/controls/PointerLockControls";
|
3
|
-
import { onKeyDown, onKeyUp } from "./eventListeners";
|
3
|
+
import { onKeyDown, onKeyUp, onMouseDown, onMouseUp } from "./eventListeners";
|
4
4
|
import { updateCameraPosition } from "./camera";
|
5
|
+
import { PivotPoint } from "./PivotPoint";
|
5
6
|
|
6
7
|
// three.js objects (https://threejs.org/docs/#manual/en/introduction/Creating-a-scene)
|
7
8
|
const scene = new THREE.Scene();
|
@@ -21,6 +22,8 @@ const sphere = new THREE.Mesh(
|
|
21
22
|
new THREE.MeshBasicMaterial({ color: 0xff0000 })
|
22
23
|
);
|
23
24
|
const pointerLockControls = new PointerLockControls(camera, document.body);
|
25
|
+
const pivotParent = new PivotPoint(scene, sphere, camera);
|
26
|
+
const raycaster = new THREE.Raycaster();
|
24
27
|
|
25
28
|
// state variables
|
26
29
|
const state = {
|
@@ -30,6 +33,10 @@ const state = {
|
|
30
33
|
left: false,
|
31
34
|
right: false,
|
32
35
|
},
|
36
|
+
mouse: {
|
37
|
+
isMouseDown: false,
|
38
|
+
prevIsMouseDown: false,
|
39
|
+
},
|
33
40
|
};
|
34
41
|
|
35
42
|
// setup function: we call this function only once at the beginning of the program to setup the scene
|
@@ -41,6 +48,8 @@ function setup() {
|
|
41
48
|
document.body.addEventListener("click", (e) => pointerLockControls.lock());
|
42
49
|
document.body.addEventListener("keydown", (e) => onKeyDown(e, state));
|
43
50
|
document.body.addEventListener("keyup", (e) => onKeyUp(e, state));
|
51
|
+
document.body.addEventListener("mousedown", () => onMouseDown(state));
|
52
|
+
document.body.addEventListener("mouseup", () => onMouseUp(state));
|
44
53
|
|
45
54
|
// add objects to the scene
|
46
55
|
scene.add(plane);
|
@@ -53,6 +62,33 @@ function setup() {
|
|
53
62
|
// define the animation loop function which will run ones per frame (typically 60fps)
|
54
63
|
function animate() {
|
55
64
|
const cameraDir = updateCameraPosition(camera, state);
|
65
|
+
const oneUnitFromCamera = camera.position.clone().add(cameraDir);
|
66
|
+
|
67
|
+
// shoot a ray from the camera to the center of the screen
|
68
|
+
raycaster.setFromCamera({ x: 0, y: 0 }, camera);
|
69
|
+
const intersects = raycaster.intersectObject(sphere);
|
70
|
+
|
71
|
+
// CASE: mousedown
|
72
|
+
if (state.mouse.isMouseDown) {
|
73
|
+
if (intersects.length > 0) {
|
74
|
+
// Only set the pivot point if this is the first frame the mouse is held down
|
75
|
+
if (!state.mouse.prevIsMouseDown) pivotParent.anchor(intersects[0].point);
|
76
|
+
}
|
77
|
+
|
78
|
+
pivotParent.setPos(oneUnitFromCamera);
|
79
|
+
document.getElementById("crosshair").style.borderColor = "blue";
|
80
|
+
}
|
81
|
+
|
82
|
+
// CASE: mouseup
|
83
|
+
else if (!state.mouse.isMouseDown) {
|
84
|
+
// let go of ball: detach it from pivotParent (reattaches it to the scene directly)
|
85
|
+
pivotParent.detach();
|
86
|
+
|
87
|
+
document.getElementById("crosshair").style.borderColor = "white";
|
88
|
+
}
|
89
|
+
|
90
|
+
// remember current state to be used in next frame
|
91
|
+
state.mouse.prevIsMouseDown = state.mouse.isMouseDown;
|
56
92
|
|
57
93
|
renderer.render(scene, camera);
|
58
94
|
}
|
In the first frame that the user holds down the left mouse button, we will use a THREE.Raycaster
to send out a ray from the camera into the center of the screen (that's where the cursor/crosshair is located). If that ray intersects the ball, then we will 'set' the anchor position to be the intersection point of the ray with the ball (this will be the ray's entry point on the ball's surface).
Then, we attach the ball object to the anchor object (called the pivot parent - this is the invisible 'virtual' handle, represented by the red dot in the diagram above). We will then reposition the pivot parent to the coordinates of the point 1 unit away from our camera (using camera.position
and cameraDir
, we compute the position vector oneUnitAwayFromCamera
). Clicking on the ball will look like the ball is pulled towards the camera, and that is because we have not implemented scaling the ball proportionally yet.
However, the crosshair's location on the ball will be preserved. If you initially clicked on the ball's top right corner ⌝, the ball will move towards from the camera in such a way that the top right corner ⌝ will still be under the crosshair post-repositioning. Without using this 'pivot point' method, we would've had to reposition the ball by directly setting the ball's
Object3D.position
property, which would have centered it directly under the crosshair. This is undesireable.
In the following frames, we will repeatedly recompute the target position oneUnitAwayFromCamera
, and reposition the pivot point there, until the user releases the mouse. And when they eventually click and hold the ball again, we will repeat the same process, resetting the anchor/pivot point to the intersection point of the ray with the ball every time.
From the demo above, observe that the crosshair turns from white to blue when the left mouse button is held down (apologies if it's tiny for smaller screens). Picking up the ball works, as far as moving the ball together with the camera goes. However, the ball still appears to snap forward (towards the camera). We will fix this abrupt movement soon, I promise. But for now, we have another challenge to tackle.
Observe this next demo closely:
Notice that I am not moving the camera's position with 'WASD' in this clip. Instead, as we rotate the camera, the ball appears to swivel around the pivot point.
I've given you a little help by drawing a blue bounding box around the invisible
pivotParent
object.
This isn't quite the desired effect that we were looking for. We want to simulate actually picking up the object, meaning the object shouldn't appear to be swiveling around the pivot point like that.
Extra exercise: For the hungry learners, I encourage you to add some code to draw a helper indicator point by adding another instance of
Three.Object3D
to the scene (could be any geometry, likeSphere
orBox
), and repeatedly set this object's position to mirrorpivotParent.obj.position
on every frame! This will help you visualize the pivot point's position in the scene, and see where on the ball's surface does it land. This way, the pivot point will be visible to you, and you can see how it moves around as you move the ball.
I have created a diagram to illustrate the concept of counter-rotation. The key concept to understand, is that as we rotate the camera angle by degrees, we want to apply the same amount of rotation to the object in the same direction to preserve the amount of relative rotation.
Think of it this way: the moon always shows the same face to the Earth, because it rotates at the same rate that it orbits the Earth. This is called synchronous rotation. This is also where the notion of "the dark side of the moon" comes from. The moon does have a 'dark side', but it is not always dark. It is just that we never see it from Earth, because it is always facing away from us.
Here comes the (only very slightly) tricky part: ✨ Quaternion ✨ rotations. 🔗 Quaternions have become the industry standard way of representing and dealing with rotations and orientation in 3D space. This is because they do not suffer from 🔗 Gimbal Lock, which is a problem that 🔗 Euler angles have.
If you're not familiar with quaternions, do not fret. I have seen many developers who have been working with various 3D engines for years, and still do not fully understand the intricacies of quaternions (myself included, most definitely). If you are interested, 3b1b has an amazing video on 🔗 Visualizing Quaternions with Stereographic projection. But fair warning: it is not for the faint of heart, for it involves mind bending sorcery like visualizing 4D numbers (humans struggle a lot with this).
☁️ Here's the silver lining: We can treat quaternions the same way we treat transformation matrices, as they share many similar properties. Like matrices, quaternions can be multiplied to combine rotations.
Note: while both can rotate objects, quaternions apply rotations through quaternion-vector multiplication (sandwich multiplication), rather than standard matrix multiplication. But in Three.js, we do not need to worry about any of these details, because we can use the
THREE.Quaternion
class to handle all the quaternion math for us.
Every THREE.Object3D
object has a .quaternion
property, which is what we will use to manipulate the 3D orientation of that object. I will define some very light math to illustrate this concept. Ideally, you have a little bit of experience with basic linear algebra. If not, you may still be able to follow along, but the intuition will feel more foreign. Otherwise, feel free to skip this section.
I'll define as the orientation of the camera during the instance the object is picked up. This is the exact moment when we set the anchor point. I'll further define as the orientation of the camera at any given frame after the object has been picked up (the anchor has been set in a previous frame).
In order to go from the initial orientation to the current orientation , we need to apply a rotation to the object. This rotation is same the rotation that we need to apply to the object in order to ensure that the same side of the object always faces the camera (remember the previous "dark side of the moon" example: the moon always rotates to face the Earth on the same side).
We denote as the quaternion that describes the ball's orientation at the moment pivotParent
is anchored, and as the object's orientation at any given frame after it has been anchored, picked up, and is being dragged around. It is necessary to capture because the object may already have a initial rotation prior to being picked up, and we want to apply the camera's rotation on top of this initial rotation.
The equations below illustrate:
A good thing to understand is that even though the camera and object orientations are represented as quaternions, the rotation transformation, , is also a quaternion by itself (this resembles transformation vector in linear algebra, and indeed it can be helpful to think of quaternions in a similar way for simplication and familiarity. Though one must be careful - quaternions behave distinctly from vectors in certain cases).
Try and get used to the notion of using . For those new to linear algebra, think of it the same way you think of functions in mathematics. It is equivalent to saying , where is a function that applies a rotation or transformation to and returns .
Summary:
PivotPoint.capturedCameraQuaternion
, = PivotPoint.camera.quaternion
PivotPoint.capturedObjQuaternion
, = PivotPoint.obj.quaternion
Now, let us write code to rotate the ball by as much as the camera has rotated since pivotParent
was anchored.
@@ -8,6 +8,10 @@ export class PivotPoint {
|
|
8
8
|
|
9
9
|
this.obj = new THREE.Object3D();
|
10
10
|
scene.add(this.obj);
|
11
|
+
|
12
|
+
// Rotation
|
13
|
+
this.capturedObjQuaternion = new THREE.Quaternion();
|
14
|
+
this.capturedCameraQuaternion = new THREE.Quaternion();
|
11
15
|
}
|
12
16
|
|
13
17
|
detach() {
|
@@ -36,6 +40,12 @@ export class PivotPoint {
|
|
36
40
|
* Any future movement of the pivotParent will now affect the object as well.
|
37
41
|
*/
|
38
42
|
this.obj.attach(this.targetObj);
|
43
|
+
|
44
|
+
/**
|
45
|
+
* Capture the current rotations.
|
46
|
+
*/
|
47
|
+
this.capturedObjQuaternion.copy(this.obj.quaternion);
|
48
|
+
this.capturedCameraQuaternion.copy(this.camera.quaternion);
|
39
49
|
}
|
40
50
|
|
41
51
|
setPos(to) {
|
@@ -46,5 +56,17 @@ export class PivotPoint {
|
|
46
56
|
* but instead move 5 units backwards relative from its current position.
|
47
57
|
*/
|
48
58
|
this.obj.position.copy(to);
|
59
|
+
|
60
|
+
/**
|
61
|
+
* Rotate the object by as much as the camera has rotated since the pivotParent was anchored.
|
62
|
+
* Check out https://jamesyap.org/home/tutorials/superliminal#4-rotation-on-the-object for a comprehensive explanation.
|
63
|
+
* Keep in mind the usage of `.clone()` to prevent modifying the original quaternions.
|
64
|
+
*/
|
65
|
+
const rotationTransformation = this.camera.quaternion
|
66
|
+
.clone()
|
67
|
+
.multiply(this.capturedCameraQuaternion.clone().invert());
|
68
|
+
this.obj.quaternion.copy(
|
69
|
+
rotationTransformation.multiply(this.capturedObjQuaternion)
|
70
|
+
);
|
49
71
|
}
|
50
72
|
}
|
Look at that, we've been able to get rid of all the swiveling! The ball now rotates in sync with the camera, and it looks like we're actually picking up the object.
Here, we stumble into another implicit benefit of using a pivot parent object to handle the ball's movement: the center of rotation is the pivot point itself, and the ball is rotated correctly around this point. Try and imagine how this would've looked if we had directly rotated the ball object itself, with the ball's origin being the center of rotation. If the ball was a perfect sphere, this type of rotation would've accomplished absolutely nothing, as a perfect sphere will look the same from all sides no matter how you rotate it (around its own center).
Finally, we can work on making the "picking up" look seamless by proportionally scaling the ball with the distance the ball has travelled from its initial position. This will make the ball appear to have not moved, when in reality, we have repositioned it closer to the camera.
Where:
PivotPoint.obj.scale
PivotPoint.capturedObjScale
PivotPoint.getDistance()
PivotPoint.capturedObjDistance
@@ -12,6 +12,10 @@ export class PivotPoint {
|
|
12
12
|
// Rotation
|
13
13
|
this.capturedObjQuaternion = new THREE.Quaternion();
|
14
14
|
this.capturedCameraQuaternion = new THREE.Quaternion();
|
15
|
+
|
16
|
+
// Scale
|
17
|
+
this.capturedObjDistance = 1; // arbitrary initial value
|
18
|
+
this.capturedObjScale = new THREE.Vector3(1, 1, 1); // describes the scale of the object in x, y, and z directions
|
15
19
|
}
|
16
20
|
|
17
21
|
detach() {
|
@@ -46,6 +50,12 @@ export class PivotPoint {
|
|
46
50
|
*/
|
47
51
|
this.capturedObjQuaternion.copy(this.obj.quaternion);
|
48
52
|
this.capturedCameraQuaternion.copy(this.camera.quaternion);
|
53
|
+
|
54
|
+
/**
|
55
|
+
* Capture the current scale.
|
56
|
+
*/
|
57
|
+
this.capturedObjDistance = this.getDistance();
|
58
|
+
this.capturedObjScale.copy(this.obj.scale);
|
49
59
|
}
|
50
60
|
|
51
61
|
setPos(to) {
|
@@ -68,5 +78,18 @@ export class PivotPoint {
|
|
68
78
|
this.obj.quaternion.copy(
|
69
79
|
rotationTransformation.multiply(this.capturedObjQuaternion)
|
70
80
|
);
|
81
|
+
|
82
|
+
/**
|
83
|
+
* Scale the object by the same amount as the camera has moved since the pivotParent was anchored.
|
84
|
+
*/
|
85
|
+
const scaleFactor = this.getDistance() / this.capturedObjDistance;
|
86
|
+
this.obj.scale.copy(
|
87
|
+
this.capturedObjScale.clone().multiplyScalar(scaleFactor)
|
88
|
+
);
|
89
|
+
}
|
90
|
+
|
91
|
+
getDistance() {
|
92
|
+
return this.obj.position.distanceTo(this.camera.position);
|
93
|
+
// return this.obj.position.clone().sub(this.camera.position).length(); // same thing but using vector subtraction
|
71
94
|
}
|
72
95
|
}
|
Just as before, the center of scaling is also the pivot point (and not the center of the ball itself). It is easy to take such conveniences for granted, and this is why having a pivot parent object is so useful. It allows us to perform transformations around a point that is not the object's center, and this is a very powerful concept in 3D graphics.
After implementing the code changes above, when dragging the ball you will now notice that the ball doesn't appear to have changed in shape, even though it has moved closer to the camera. This is because we are scaling the ball to compensate for the distance it has moved from its initial position.
Now, instead of leaving the ball in front of the camera, we want to reposition the ball to be touching the wall when we let go of it.
@@ -16,13 +16,20 @@ export class PivotPoint {
|
|
16 |
// Scale
|
17 |
this.capturedObjDistance = 1; // arbitrary initial value
|
18 |
this.capturedObjScale = new THREE.Vector3(1, 1, 1); // describes the scale of the object in x, y, and z directions
|
|
|
|
|
|
|
19 |
}
|
20 |
|
21 |
detach() {
|
|
|
|
|
22 |
/**
|
23 |
* Attaching an object to another new object in Three.js will detach it from its current parent.
|
24 |
*/
|
25 |
this.scene.attach(this.targetObj);
|
|
|
|
|
26 |
}
|
27 |
|
28 |
anchor(from) {
|
@@ -56,9 +63,13 @@ export class PivotPoint {
|
|
56 |
*/
|
57 |
this.capturedObjDistance = this.getDistance();
|
58 |
this.capturedObjScale.copy(this.obj.scale);
|
|
|
|
|
59 |
}
|
60 |
|
61 |
setPos(to) {
|
|
|
|
|
62 |
/**
|
63 |
* We make changes to the pivotParent's position, and the object will follow along.
|
64 |
* Example: Moving the pivotParent 5 units backwards will result in the object moving 5 units backwards as well.
|
|
|
16 |
// Scale
|
17 |
this.capturedObjDistance = 1; // arbitrary initial value
|
18 |
this.capturedObjScale = new THREE.Vector3(1, 1, 1); // describes the scale of the object in x, y, and z directions
|
19 |
+
|
20 |
+
// State
|
21 |
+
this.isAttached = false;
|
22 |
}
|
23 |
|
24 |
detach() {
|
25 |
+
if (!this.isAttached) return;
|
26 |
+
|
27 |
/**
|
28 |
* Attaching an object to another new object in Three.js will detach it from its current parent.
|
29 |
*/
|
30 |
this.scene.attach(this.targetObj);
|
31 |
+
|
32 |
+
this.isAttached = false;
|
33 |
}
|
34 |
|
35 |
anchor(from) {
|
|
|
63 |
*/
|
64 |
this.capturedObjDistance = this.getDistance();
|
65 |
this.capturedObjScale.copy(this.obj.scale);
|
66 |
+
|
67 |
+
this.isAttached = true;
|
68 |
}
|
69 |
|
70 |
setPos(to) {
|
71 |
+
if (!this.isAttached) return;
|
72 |
+
|
73 |
/**
|
74 |
* We make changes to the pivotParent's position, and the object will follow along.
|
75 |
* Example: Moving the pivotParent 5 units backwards will result in the object moving 5 units backwards as well.
|
@@ -81,8 +81,26 @@ function animate() {
|
|
81 |
|
82 |
// CASE: mouseup
|
83 |
else if (!state.mouse.isMouseDown) {
|
|
|
|
|
|
|
|
|
|
|
|
|
84 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
85 |
-
|
|
|
|
|
|
|
86 |
|
87 |
document.getElementById("crosshair").style.borderColor = "white";
|
88 |
}
|
|
|
81 |
|
82 |
// CASE: mouseup
|
83 |
else if (!state.mouse.isMouseDown) {
|
84 |
+
if (pivotParent.isAttached) {
|
85 |
+
const intersectBg = raycaster.intersectObject(plane);
|
86 |
+
if (intersectBg.length === 1) {
|
87 |
+
// intersection point on the wall
|
88 |
+
const pointOnBg = intersectBg[0].point;
|
89 |
+
|
90 |
+
// shoot a ray from the point on the wall back towards the sphere (reflection)
|
91 |
+
raycaster.set(pointOnBg, cameraDir.negate());
|
92 |
+
const instersectBackside = raycaster.intersectObject(sphere);
|
93 |
+
|
94 |
+
if (instersectBackside.length === 1) {
|
95 |
+
// point on the backside surface of the sphere
|
96 |
+
const pointOnBackside = instersectBackside[0].point;
|
97 |
+
|
98 |
+
pivotParent.anchor(pointOnBackside);
|
99 |
+
pivotParent.setPos(pointOnBg);
|
100 |
+
pivotParent.detach();
|
101 |
+
}
|
102 |
+
}
|
103 |
+
}
|
104 |
|
105 |
document.getElementById("crosshair").style.borderColor = "white";
|
106 |
}
|
- If this diff view is confusing to look at, try using the "View full changes" link.
- You can browse the full project state including the complete files instead of just the changes using this.
The code above shoots a ray and reflects it off of the wall to capture the point on the backside surface of the ball. Then, we reposition the ball so that its backside surface is touching the wall. However, if you watch the animation below, you will notice that the ball experiences "clipping" still, and the effect is not perfect every time.
The animation also provides a solution to this problem. The orange dot and arrow towards the end of the animation represents this extra "pull back" step that we need to go through. Every time we release the ball, we want to save a snapshot of the orange arrow (the opposite direction of the camera: cameraDir.negate()
, because the direction is facing backwards). We save this direction vector into state.pullback.direction
.
Then, even if the player's camera has moved away from that spot, we can still continuously pull the ball backwards towards the camera's old direction until the ball is no longer clipping with the wall.
@@ -67,7 +67,7 @@ export class PivotPoint {
|
|
67 |
this.isAttached = true;
|
68 |
}
|
69 |
|
70 |
-
setPos(to) {
|
71 |
if (!this.isAttached) return;
|
72 |
|
73 |
/**
|
@@ -83,12 +83,14 @@ export class PivotPoint {
|
|
83 |
* Check out https://jamesyap.org/home/tutorials/superliminal#4-rotation-on-the-object for a comprehensive explanation.
|
84 |
* Keep in mind the usage of `.clone()` to prevent modifying the original quaternions.
|
85 |
*/
|
|
|
86 |
-
|
87 |
-
|
88 |
-
|
89 |
-
|
90 |
-
|
91 |
-
|
|
|
92 |
|
93 |
/**
|
94 |
* Scale the object by the same amount as the camera has moved since the pivotParent was anchored.
|
|
|
67 |
this.isAttached = true;
|
68 |
}
|
69 |
|
70 |
+
setPos(to, ignoreQuaternion = false) {
|
71 |
if (!this.isAttached) return;
|
72 |
|
73 |
/**
|
|
|
83 |
* Check out https://jamesyap.org/home/tutorials/superliminal#4-rotation-on-the-object for a comprehensive explanation.
|
84 |
* Keep in mind the usage of `.clone()` to prevent modifying the original quaternions.
|
85 |
*/
|
86 |
+
if (!ignoreQuaternion) {
|
87 |
+
const rotationTransformation = this.camera.quaternion
|
88 |
+
.clone()
|
89 |
+
.multiply(this.capturedCameraQuaternion.clone().invert());
|
90 |
+
this.obj.quaternion.copy(
|
91 |
+
rotationTransformation.multiply(this.capturedObjQuaternion)
|
92 |
+
);
|
93 |
+
}
|
94 |
|
95 |
/**
|
96 |
* Scale the object by the same amount as the camera has moved since the pivotParent was anchored.
|
@@ -37,6 +37,9 @@ const state = {
|
|
37 |
isMouseDown: false,
|
38 |
prevIsMouseDown: false,
|
39 |
},
|
|
|
|
|
|
|
40 |
};
|
41 |
|
42 |
// setup function: we call this function only once at the beginning of the program to setup the scene
|
@@ -68,8 +71,29 @@ function animate() {
|
|
68 |
raycaster.setFromCamera({ x: 0, y: 0 }, camera);
|
69 |
const intersects = raycaster.intersectObject(sphere);
|
70 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
71 |
// CASE: mousedown
|
72 |
-
if (state.mouse.isMouseDown) {
|
73 |
if (intersects.length > 0) {
|
74 |
// Only set the pivot point if this is the first frame the mouse is held down
|
75 |
if (!state.mouse.prevIsMouseDown) pivotParent.anchor(intersects[0].point);
|
@@ -97,7 +121,12 @@ function animate() {
|
|
97 |
|
98 |
pivotParent.anchor(pointOnBackside);
|
99 |
pivotParent.setPos(pointOnBg);
|
|
|
|
|
|
|
|
|
|
|
100 |
-
|
101 |
}
|
102 |
}
|
103 |
}
|
|
|
37 |
isMouseDown: false,
|
38 |
prevIsMouseDown: false,
|
39 |
},
|
40 |
+
pullback: {
|
41 |
+
direction: null, // THREE.Vector3() - direction to pull back the ball (away from the wall, towards the position of the camera when the mouse was released)
|
42 |
+
},
|
43 |
};
|
44 |
|
45 |
// setup function: we call this function only once at the beginning of the program to setup the scene
|
|
|
71 |
raycaster.setFromCamera({ x: 0, y: 0 }, camera);
|
72 |
const intersects = raycaster.intersectObject(sphere);
|
73 |
|
74 |
+
// CASE: pullback required (mouse actions ignored until stable state is reached)
|
75 |
+
if (state.pullback.direction) {
|
76 |
+
// mess around with this value. the higher the SPEED, the faster the pullback is completed, but the less accurate it will be
|
77 |
+
const SPEED = 0.1;
|
78 |
+
|
79 |
+
pivotParent.setPos(
|
80 |
+
pivotParent.obj.position
|
81 |
+
.clone()
|
82 |
+
.addScaledVector(state.pullback.direction, SPEED),
|
83 |
+
true // ignore camera quaternion changes:- we only want to pull the object backwards, not rotate it with the camera even after letting it go
|
84 |
+
);
|
85 |
+
|
86 |
+
const objBoundingBox = new THREE.Box3().setFromObject(sphere);
|
87 |
+
const bgBoundingBox = new THREE.Box3().setFromObject(plane);
|
88 |
+
|
89 |
+
if (!objBoundingBox.intersectsBox(bgBoundingBox)) {
|
90 |
+
state.pullback.direction = null;
|
91 |
+
pivotParent.detach();
|
92 |
+
}
|
93 |
+
}
|
94 |
+
|
95 |
// CASE: mousedown
|
96 |
+
else if (state.mouse.isMouseDown) {
|
97 |
if (intersects.length > 0) {
|
98 |
// Only set the pivot point if this is the first frame the mouse is held down
|
99 |
if (!state.mouse.prevIsMouseDown) pivotParent.anchor(intersects[0].point);
|
|
|
121 |
|
122 |
pivotParent.anchor(pointOnBackside);
|
123 |
pivotParent.setPos(pointOnBg);
|
124 |
+
|
125 |
+
// compute the direction vector [point on wall --> camera], and normalize (set magnitude to 1, unit vector)
|
126 |
+
state.pullback.direction = camera.position
|
127 |
+
.clone()
|
128 |
+
.sub(pointOnBg)
|
129 |
+
.normalize();
|
130 |
}
|
131 |
}
|
132 |
}
|
That's it!! 🎉🥳👏
If you made it all the way here, really give yourself a nice generous pat on the back. Congratulations.
Place your mouse on the canvas, hold the left mouse button, and drag.
I have a confession to make. Initially, when I first started, I had made many, many separate version of this project, using a completely different approaches. I was trying different things, inspired by other people's attempts like 🔗 Remaking Superliminal's Mindbending Illusions... by Mujj and 🔗 Superliminal Perspective Scaling by BojanV03.
I've tried using virtual objects by making an invisible clone of the ball to compute the intersection points ahead of time before actually moving the real ball. I've tried casting many rays to project a shadow of the ball to the back wall using randomization and/or downsampling, and then computing the ray which has the least distance. Then I used this ray to move the ball backwards without the need of the "pull back" step.
These attemps all worked to a certain degree of effectiveness, but the one I chose for this tutorial is the one that I found to be the most robust and reliable. It is also the one that I found to be the most intuitive to understand and implement.
Believe me, here is a glimpse into one of my previous attempts:
© 2025 James Yap
Personal Website and Knowledge Base