diff --git a/.gitignore b/.gitignore index 496ee2c..467ed02 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,3 @@ -.DS_Store \ No newline at end of file +.DS_Store +.wrangler +deploy.md \ No newline at end of file diff --git a/calibration.md b/calibration.md new file mode 100644 index 0000000..792ad5a --- /dev/null +++ b/calibration.md @@ -0,0 +1,91 @@ +# Posicionamento de Imagem em WebXR + +## Abordagem: Two-Handed Grab + +O posicionamento da imagem e feito segurando ambos os grips simultaneamente. Isso permite mover, rotacionar (3 eixos) e escalar a imagem livremente no espaco 3D, sem etapa de calibracao. + +### Como Funciona + +1. **Inicio do grab**: Ao pressionar ambos os grips, calcula-se uma matriz de referencia ("grab space") a partir das posicoes e orientacoes dos dois controles, e armazena-se a transformacao relativa da imagem nesse espaco (`grabLocalMatrix`). + +2. **Durante o grab**: A cada frame, recalcula-se a matriz de referencia com as novas posicoes/orientacoes e aplica-se a transformacao relativa armazenada. + +3. **Fim do grab**: Ao soltar qualquer grip, a imagem permanece onde foi deixada. + +### Calculo da Grab Matrix + +A matriz e construida a partir de: + +- **Posicao**: ponto medio entre os dois controles +- **Rotacao**: frame de coordenadas derivado de: + - Eixo X: direcao do controle 1 ao controle 2 + - Eixo Y: "up" medio dos dois controles (via `getWorldQuaternion`) + - Eixo Z: produto vetorial para completar o frame ortonormal +- **Escala**: tratada separadamente via razao de distancia entre controles + +```javascript +function computeGrabMatrix(c1Pos, c2Pos, c1Quat, c2Quat) { + const midpoint = c1Pos.clone().add(c2Pos).multiplyScalar(0.5); + const direction = c2Pos.clone().sub(c1Pos).normalize(); + + // Media do "up" real dos controles para rotacao 3-eixos + const up1 = new THREE.Vector3(0, 1, 0).applyQuaternion(c1Quat); + const up2 = new THREE.Vector3(0, 1, 0).applyQuaternion(c2Quat); + const avgUp = up1.add(up2).normalize(); + + let forward = new THREE.Vector3().crossVectors(avgUp, direction); + forward.normalize(); + const correctedUp = new THREE.Vector3().crossVectors(direction, forward).normalize(); + + const rotMatrix = new THREE.Matrix4().makeBasis(direction, correctedUp, forward); + const quaternion = new THREE.Quaternion().setFromRotationMatrix(rotMatrix); + + return new THREE.Matrix4().compose(midpoint, quaternion, new THREE.Vector3(1, 1, 1)); +} +``` + +### Graus de Liberdade + +| Gesto | Efeito | +|-------|--------| +| Mover ambas as maos juntas | Translacao (XYZ) | +| Girar as maos como um volante | Rotacao no eixo entre controles | +| Inclinar ambas as maos frente/tras | Pitch | +| Inclinar ambas as maos lateralmente | Roll | +| Afastar/aproximar as maos | Escala (pinch-to-zoom) | + +### Escala + +A escala e calculada pela razao entre a distancia atual e a distancia inicial dos controles no momento do grab: + +```javascript +currentScale = grabStartScale * (newDistance / grabStartDistance); +``` + +Limitada ao intervalo `[0.01, 4.0]`. + +## Controles Adicionais + +| Controle | Acao | +|----------|------| +| Analogico horizontal | Opacidade (+/-) | +| Analogico vertical | Escala (+/-) | +| A ou X | Mostrar/esconder imagem | +| B ou Y | Mostrar/esconder instrucoes | + +Os controles de analogico sao desabilitados durante o grab para evitar conflitos. + +## Armadilhas Comuns + +### controller.position vs localToWorld + +Em Three.js WebXR, o controller tem `matrixAutoUpdate = false` e a pose XR e aplicada direto na `matrix`/`matrixWorld`. Usar `.position` pode retornar (0,0,0) ou valor defasado. **Sempre usar:** + +```javascript +// Posicao world correta +const worldPos = controller.localToWorld(new THREE.Vector3(0, 0, 0)); + +// Quaternion world correto +const worldQuat = new THREE.Quaternion(); +controller.getWorldQuaternion(worldQuat); +``` diff --git a/index.html b/index.html index 8967293..123204d 100644 --- a/index.html +++ b/index.html @@ -69,12 +69,13 @@ varying vec2 vUv; float intensity(in vec4 color) { - return sqrt((color.x*color.x)+(color.y*color.y)+(color.z*color.z)); + float raw = sqrt((color.x*color.x)+(color.y*color.y)+(color.z*color.z)) / 1.732; + return pow(raw, 0.4); } void main() { vec2 uv = vUv; - + ivec2 imageSize = textureSize(colorTexture, 0); vec2 step = vec2(stepSize/float(imageSize.x), stepSize/float(imageSize.y)); @@ -86,23 +87,22 @@ float topRight = intensity(texture2D(colorTexture, uv + vec2(step.x, step.y))); float right = intensity(texture2D(colorTexture, uv + vec2(step.x, 0))); float bottomRight = intensity(texture2D(colorTexture, uv + vec2(step.x, -step.y))); - + float horizontal = topLeft + 2.0 * left + bottomLeft - topRight - 2.0 * right - bottomRight; float vertical = -topLeft - 2.0 * top - topRight + bottomLeft + 2.0 * bottom + bottomRight; - float sobelResult = sqrt(horizontal * horizontal + vertical * vertical); + float sobelResult = clamp(sqrt(horizontal * horizontal + vertical * vertical) * 2.5, 0.0, 1.0); - vec4 sobelColor = vec4(1.0 - sobelResult, 1.0 - sobelResult, 1.0 - sobelResult, sobelResult); + vec3 sobelRGB = vec3(1.0 - sobelResult); vec4 currentColor = texture2D(colorTexture, uv); - float smoothOpacity = smoothstep(0.0, 0.3, opacity); + float visibility = smoothstep(0.0, 0.4, opacity); + float imageBlend = smoothstep(0.4, 0.9, opacity); - gl_FragColor = vec4( - currentColor.x * smoothOpacity + (1.0 - smoothOpacity) * sobelColor.x, - currentColor.y * smoothOpacity + (1.0 - smoothOpacity) * sobelColor.y, - currentColor.z * smoothOpacity + (1.0 - smoothOpacity) * sobelColor.z, - currentColor.w * min(opacity, smoothOpacity) + (1.0 - smoothOpacity) * sobelColor.w - ); + vec3 rgb = mix(sobelRGB, currentColor.rgb, imageBlend); + float alpha = mix(sobelResult, 1.0, imageBlend) * visibility; + + gl_FragColor = vec4(rgb, alpha); } @@ -128,6 +128,28 @@ let previousDidPressHideImage = false; let previousDidPressHideInstructions = false; + // ============================================================ + // DELTA-BASED GRAB STATE + // Instead of computing absolute rotation each frame, we track + // the CHANGE between frames and apply it to the object. + // ============================================================ + + // Two-hand grab state + let isTwoHandGrabbing = false; + let prevGrabMidpoint = null; + let prevGrabQuat = null; + let prevGrabDistance = 0; + + // Single-hand grab state (trigger on either controller) + let singleGrabControllerIndex = -1; + let prevSingleGrabPos = null; + let prevSingleGrabQuat = null; + + // Hit-test state + let hitTestSource = null; + let hitTestReticle = null; + let hitTestPose = null; + init(); animate(); @@ -146,7 +168,6 @@ clock = new THREE.Clock(); // Renderer - renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true }); renderer.setPixelRatio(window.devicePixelRatio); renderer.setSize(window.innerWidth, window.innerHeight); @@ -157,23 +178,32 @@ container.appendChild(renderer.domElement); // Controllers - controller1 = renderer.xr.getController(0); scene.add(controller1); controller2 = renderer.xr.getController(1); scene.add(controller2); - // UI + // Hit-test reticle (green ring on detected surfaces) + const reticleGeometry = new THREE.RingGeometry(0.04, 0.05, 32).rotateX(-Math.PI / 2); + const reticleMaterial = new THREE.MeshBasicMaterial({ + color: 0x00ff88, + transparent: true, + opacity: 0.6, + side: THREE.DoubleSide + }); + hitTestReticle = new THREE.Mesh(reticleGeometry, reticleMaterial); + hitTestReticle.visible = false; + hitTestReticle.matrixAutoUpdate = false; + scene.add(hitTestReticle); + // UI makeInstructionsUI(); // Load image - const length = 0.25; group = new THREE.Group(); group.position.set(0, 0, -0.5); - scene.add(group); const url = new URL(window.location); @@ -192,16 +222,13 @@ } // Resize event - window.addEventListener('resize', onWindowResize); // URL Input - const inputLink = document.getElementById("urlInputLink"); inputLink.onclick = changeImageUrl; // Send to Quest - const sendToQuestLink = document.getElementById("urlToQuestLink"); sendToQuestLink.onclick = sendUrlToQuest; } @@ -210,7 +237,7 @@ const textContainer = new ThreeMeshUI.Block({ borderRadius: 0.05, width: 0.45, - height: 0.60, + height: 0.68, padding: 0.05, textAlign: 'left', fontKerning: 'none', @@ -228,86 +255,81 @@ fontSize: 0.04, fontColor: new THREE.Color(0xffff00) }), - new ThreeMeshUI.Text({ content: "\nAnalog right: ", fontSize: 0.025, fontColor: new THREE.Color(0xffff00) }), - new ThreeMeshUI.Text({ content: "Increase opacity", fontSize: 0.025, fontColor: new THREE.Color(0xffffff) }), - new ThreeMeshUI.Text({ content: "\nAnalog left: ", fontSize: 0.025, fontColor: new THREE.Color(0xffff00) }), - new ThreeMeshUI.Text({ content: "Decrease opacity", fontSize: 0.025, fontColor: new THREE.Color(0xffffff) }), - new ThreeMeshUI.Text({ content: "\n\nAnalog up: ", fontSize: 0.025, fontColor: new THREE.Color(0xffff00) }), - new ThreeMeshUI.Text({ content: "Increase size", fontSize: 0.025, fontColor: new THREE.Color(0xffffff) }), - new ThreeMeshUI.Text({ content: "\nAnalog down: ", fontSize: 0.025, fontColor: new THREE.Color(0xffff00) }), - new ThreeMeshUI.Text({ content: "Decrease size", fontSize: 0.025, fontColor: new THREE.Color(0xffffff) }), - - new ThreeMeshUI.Text({ - content: "\n\nHold trigger: ", + content: "\n\nTrigger: ", fontSize: 0.025, fontColor: new THREE.Color(0xffff00) }), - new ThreeMeshUI.Text({ - content: "Reposition image", + content: "Grab with one hand", + fontSize: 0.025, + fontColor: new THREE.Color(0xffffff) + }), + new ThreeMeshUI.Text({ + content: "\nBoth grips: ", + fontSize: 0.025, + fontColor: new THREE.Color(0xffff00) + }), + new ThreeMeshUI.Text({ + content: "Grab + rotate + scale", fontSize: 0.025, fontColor: new THREE.Color(0xffffff) }), - new ThreeMeshUI.Text({ content: "\n\nA or X: ", fontSize: 0.025, fontColor: new THREE.Color(0xffff00) }), - new ThreeMeshUI.Text({ - content: "Show/Hide image", + content: "Snap to surface / Show-Hide", fontSize: 0.025, fontColor: new THREE.Color(0xffffff) }), - new ThreeMeshUI.Text({ content: "\nB or Y: ", fontSize: 0.025, fontColor: new THREE.Color(0xffff00) }), - new ThreeMeshUI.Text({ content: "Show/Hide instructions", fontSize: 0.025, @@ -337,14 +359,35 @@ } function loadTexture(container, length, url) { - const loader = new THREE.TextureLoader(); loader.load( url, - function (texture) { - document.body.appendChild(ARButton.createButton(renderer)); + // Request hit-test as optional feature + const sessionInit = { + requiredFeatures: ['local-floor'], + optionalFeatures: ['hit-test'] + }; + + const arButton = ARButton.createButton(renderer, sessionInit); + document.body.appendChild(arButton); + + // Setup hit-test when XR session starts + renderer.xr.addEventListener('sessionstart', async () => { + const session = renderer.xr.getSession(); + try { + const viewerSpace = await session.requestReferenceSpace('viewer'); + hitTestSource = await session.requestHitTestSource({ space: viewerSpace }); + } catch (e) { + console.log('Hit-test not available:', e); + hitTestSource = null; + } + }); + + renderer.xr.addEventListener('sessionend', () => { + hitTestSource = null; + }); let originalWidth = texture.image.width; let originalHeight = texture.image.height; @@ -354,7 +397,6 @@ let height = (originalHeight/max) * length; const geometry = new THREE.PlaneGeometry(width, height); - texture.generateMipmaps = false; const sobelMaterial = new THREE.ShaderMaterial({ @@ -369,12 +411,9 @@ }); imagePlane = new THREE.Mesh(geometry, sobelMaterial); - container.add(imagePlane); }, - undefined, - function (err) { alert("Unable to load image, try a different URL.\n\nCross-Origin Resource Sharing (CORS) policies might prevent the app from loading images from certain websites."); } @@ -384,7 +423,6 @@ function incrementOpacity(delta) { currentOpacity += delta; currentOpacity = Math.min(Math.max(currentOpacity, 0.0), 1.0); - if (imagePlane != null) { imagePlane.material.uniforms["opacity"].value = currentOpacity; } @@ -392,10 +430,9 @@ function incrementScale(delta) { currentScale += delta; - currentScale = Math.min(Math.max(currentScale, 0.0), 4.0); - + currentScale = Math.min(Math.max(currentScale, 0.01), 4.0); if (group != null) { - group.scale.set(currentScale, currentScale, currentScale); + group.scale.setScalar(currentScale); } } @@ -405,19 +442,217 @@ renderer.setSize( window.innerWidth, window.innerHeight ); } - // - function animate() { renderer.setAnimationLoop(loop); } - function loop() { - updateInput(); + function loop(timestamp, frame) { + updateHitTest(frame); + updateInput(frame); ThreeMeshUI.update(); renderer.render(scene, camera); } - function updateInput() { + // ============================================================ + // HIT-TEST: detect real surfaces and show reticle + // ============================================================ + + function updateHitTest(frame) { + if (!frame || !hitTestSource) { + hitTestReticle.visible = false; + return; + } + + const refSpace = renderer.xr.getReferenceSpace(); + const results = frame.getHitTestResults(hitTestSource); + + if (results.length > 0) { + const hit = results[0]; + const pose = hit.getPose(refSpace); + if (pose) { + hitTestReticle.visible = true; + hitTestReticle.matrix.fromArray(pose.transform.matrix); + hitTestPose = pose; + } else { + hitTestReticle.visible = false; + hitTestPose = null; + } + } else { + hitTestReticle.visible = false; + hitTestPose = null; + } + } + + // ============================================================ + // SNAP TO SURFACE: place image flat on detected surface + // ============================================================ + + function snapToSurface() { + if (!hitTestPose || !group) return false; + + const matrix = new THREE.Matrix4().fromArray(hitTestPose.transform.matrix); + const pos = new THREE.Vector3(); + const quat = new THREE.Quaternion(); + const scl = new THREE.Vector3(); + matrix.decompose(pos, quat, scl); + + // Offset slightly above the surface to prevent z-fighting + pos.y += 0.002; + group.position.copy(pos); + group.quaternion.copy(quat); + + // Lay the plane flat on the surface. + // Hit-test Y = surface normal, so rotate -90deg around X + const layFlat = new THREE.Quaternion().setFromAxisAngle( + new THREE.Vector3(1, 0, 0), -Math.PI / 2 + ); + group.quaternion.multiply(layFlat); + + return true; + } + + // ============================================================ + // HELPER: Get controller world position and quaternion + // In WebXR, controller.position can be stale. Always use these. + // ============================================================ + + function getControllerWorldPos(index) { + const c = renderer.xr.getController(index); + return c.localToWorld(new THREE.Vector3(0, 0, 0)); + } + + function getControllerWorldQuat(index) { + const c = renderer.xr.getController(index); + const q = new THREE.Quaternion(); + c.getWorldQuaternion(q); + return q; + } + + // ============================================================ + // DELTA-BASED TWO-HAND GRAB + // + // Key insight: instead of computing "where should the image be" + // from scratch each frame, we compute "how did the hands change + // since last frame" and apply that delta to the current pose. + // + // This captures ALL rotations faithfully (pitch, yaw, roll) + // because we use the average quaternion of both controllers + // directly via SLERP, rather than trying to construct rotation + // from axis vectors (which was the root cause of the old bug). + // ============================================================ + + function computeTwoHandPose(c1Pos, c2Pos, c1Quat, c2Quat) { + const midpoint = c1Pos.clone().add(c2Pos).multiplyScalar(0.5); + + // SLERP at 0.5 = average orientation of both hands. + // Directly captures actual tilt/pitch/roll. + const avgQuat = c1Quat.clone().slerp(c2Quat, 0.5); + + const distance = c1Pos.distanceTo(c2Pos); + + return { midpoint, quat: avgQuat, distance }; + } + + function updateTwoHandGrab(c1Pos, c2Pos, c1Quat, c2Quat) { + const current = computeTwoHandPose(c1Pos, c2Pos, c1Quat, c2Quat); + + if (!isTwoHandGrabbing) { + // === GRAB START === + isTwoHandGrabbing = true; + prevGrabMidpoint = current.midpoint.clone(); + prevGrabQuat = current.quat.clone(); + prevGrabDistance = current.distance; + return; + } + + // === GRAB UPDATE: compute delta and apply === + + // 1. TRANSLATION DELTA + const deltaPos = current.midpoint.clone().sub(prevGrabMidpoint); + group.position.add(deltaPos); + + // 2. ROTATION DELTA + // deltaQuat = current * inverse(previous) + // = "how much did the hands rotate since last frame" + const prevQuatInv = prevGrabQuat.clone().invert(); + const deltaQuat = current.quat.clone().multiply(prevQuatInv); + + // Apply rotation around the grab midpoint (natural pivot) + const pivot = current.midpoint; + const objToPivot = group.position.clone().sub(pivot); + objToPivot.applyQuaternion(deltaQuat); + group.position.copy(pivot).add(objToPivot); + group.quaternion.premultiply(deltaQuat); + + // 3. SCALE DELTA (pinch-to-zoom) + if (prevGrabDistance > 0.01 && current.distance > 0.01) { + const scaleRatio = current.distance / prevGrabDistance; + currentScale *= scaleRatio; + currentScale = Math.min(Math.max(currentScale, 0.01), 4.0); + group.scale.setScalar(currentScale); + } + + // Store for next frame + prevGrabMidpoint = current.midpoint.clone(); + prevGrabQuat = current.quat.clone(); + prevGrabDistance = current.distance; + } + + function endTwoHandGrab() { + isTwoHandGrabbing = false; + prevGrabMidpoint = null; + prevGrabQuat = null; + prevGrabDistance = 0; + } + + // ============================================================ + // SINGLE-HAND GRAB (trigger button) + // + // Press trigger with one hand to grab and move/rotate the image. + // Uses the same delta technique — tracks the controller's + // quaternion change frame-to-frame and applies it directly. + // ============================================================ + + function updateSingleHandGrab(controllerIndex) { + const pos = getControllerWorldPos(controllerIndex); + const quat = getControllerWorldQuat(controllerIndex); + + if (singleGrabControllerIndex !== controllerIndex) { + // === GRAB START === + singleGrabControllerIndex = controllerIndex; + prevSingleGrabPos = pos.clone(); + prevSingleGrabQuat = quat.clone(); + return; + } + + // === GRAB UPDATE === + const deltaPos = pos.clone().sub(prevSingleGrabPos); + group.position.add(deltaPos); + + const prevQuatInv = prevSingleGrabQuat.clone().invert(); + const deltaQuat = quat.clone().multiply(prevQuatInv); + + const pivot = pos; + const objToPivot = group.position.clone().sub(pivot); + objToPivot.applyQuaternion(deltaQuat); + group.position.copy(pivot).add(objToPivot); + group.quaternion.premultiply(deltaQuat); + + prevSingleGrabPos = pos.clone(); + prevSingleGrabQuat = quat.clone(); + } + + function endSingleHandGrab() { + singleGrabControllerIndex = -1; + prevSingleGrabPos = null; + prevSingleGrabQuat = null; + } + + // ============================================================ + // INPUT PROCESSING + // ============================================================ + + function updateInput(frame) { const xrSession = renderer.xr.getSession(); const elapsedTime = clock.getDelta(); @@ -428,44 +663,58 @@ return; } + let bothGripsPressed = true; + let controllerPositions = []; + let controllerQuaternions = []; + let validControllerCount = 0; + let triggerControllerIndex = -1; + for (let i = 0; i < xrSession.inputSources.length; i++) { const input = xrSession.inputSources[i]; - const gamepad = input.gamepad; + const gamepad = input.gamepad; if ( - gamepad != null && + gamepad != null && gamepad.mapping == "xr-standard" && gamepad.axes.length >= 4 ) { - const horizontal = gamepad.axes[2]; // Primary thumbstick X - const vertical = gamepad.axes[3]; // Primary thumbstick Y + validControllerCount++; + + const horizontal = gamepad.axes[2]; + const vertical = gamepad.axes[3]; const triggerButton = gamepad.buttons[0]; + const gripButton = gamepad.buttons[1]; const axButton = gamepad.buttons[4]; const byButton = gamepad.buttons[5]; - if (vertical > 0.7) { - incrementScale(-1.0 * elapsedTime); - } else if (vertical < -0.7) { - incrementScale(1.0 * elapsedTime); + if (!gripButton.pressed) { + bothGripsPressed = false; } - if (horizontal > 0.7) { - incrementOpacity(1.0 * elapsedTime); - } else if (horizontal < -0.7) { - incrementOpacity(-1.0 * elapsedTime); + // Track trigger for single-hand grab + if (triggerButton.pressed) { + triggerControllerIndex = i; } - // FIXME: This index won't match the correct controller - // if a disconnection happens during the session. - const controllerToUse = renderer.xr.getController(i); - - if (triggerButton.pressed) { - const offsetPosition = controllerToUse.localToWorld(new THREE.Vector3(0, 0, 0.045)); - group.position.copy(offsetPosition); - group.setRotationFromQuaternion(controllerToUse.quaternion); + // Thumbstick controls (only when not grabbing) + if (!isTwoHandGrabbing && singleGrabControllerIndex < 0) { + if (vertical > 0.7) { + incrementScale(-1.0 * elapsedTime); + } else if (vertical < -0.7) { + incrementScale(1.0 * elapsedTime); + } + + if (horizontal > 0.7) { + incrementOpacity(1.0 * elapsedTime); + } else if (horizontal < -0.7) { + incrementOpacity(-1.0 * elapsedTime); + } } + controllerPositions.push(getControllerWorldPos(i)); + controllerQuaternions.push(getControllerWorldQuat(i)); + if (axButton.pressed) { didPressHideImage = true; } @@ -476,18 +725,49 @@ } } - const shouldChangeInstructionsVisibility = (!didPressHideInstructions && previousDidPressHideInstructions); - previousDidPressHideInstructions = didPressHideInstructions; + bothGripsPressed = bothGripsPressed && validControllerCount >= 2 && controllerPositions.length >= 2; - if (shouldChangeInstructionsVisibility) { - instructionsPlane.visible = !instructionsPlane.visible; + // === PRIORITY: Two-hand grab > Single-hand grab === + + if (bothGripsPressed) { + if (singleGrabControllerIndex >= 0) { + endSingleHandGrab(); + } + + updateTwoHandGrab( + controllerPositions[0], controllerPositions[1], + controllerQuaternions[0], controllerQuaternions[1] + ); + } else { + if (isTwoHandGrabbing) { + endTwoHandGrab(); + } + + if (triggerControllerIndex >= 0) { + updateSingleHandGrab(triggerControllerIndex); + } else if (singleGrabControllerIndex >= 0) { + endSingleHandGrab(); + } } - const shouldChangeImageVisibility = (!didPressHideImage && previousDidPressHideImage); + // === A/X: snap to surface if available, else toggle visibility === + + const shouldHandleAX = (!didPressHideImage && previousDidPressHideImage); previousDidPressHideImage = didPressHideImage; - if (shouldChangeImageVisibility) { - imagePlane.visible = !imagePlane.visible; + if (shouldHandleAX) { + if (!snapToSurface()) { + imagePlane.visible = !imagePlane.visible; + } + } + + // === B/Y: toggle instructions === + + const shouldChangeInstructionsVisibility = (!didPressHideInstructions && previousDidPressHideInstructions); + previousDidPressHideInstructions = didPressHideInstructions; + + if (shouldChangeInstructionsVisibility) { + instructionsPlane.visible = !instructionsPlane.visible; } }