每日算法 — 使用java实现纪念碑谷:透视投影变换与不可能图形路径规划

一、游戏背景

《纪念碑谷》(Monument Valley)是一款以 不可能图形(Impossible Figures)为核心的解谜游戏。游戏通过旋转、错位、视觉错觉让玩家引导主角艾达在看似不可能的空间中移动到终点。

游戏背后的算法涉及:
– 透视投影变换(Perspective Projection)
– 三维到二维的几何映射
– 基于图论的路径搜索
– 不可能图形的几何约束检测

二、核心思想

2.1 不可能图形的数学基础

游戏中典型的”彭罗斯三角形”(Penrose Triangle)效果,本质上是 多视角投影不一致 的视觉欺骗。算法实现需要:

  1. 维护3D世界坐标:建筑在3D空间中的真实位置
  2. 维护2D屏幕投影:将3D坐标投影到屏幕时,根据相机角度产生视觉欺骗
  3. 路径可达性判定:判断艾达在视觉上能通过的路径,在3D世界是否真的可行

2.2 投影变换

使用 透视投影:

$$P_{screen} = \begin{pmatrix} \frac{f \cdot x}{z} \ \frac{f \cdot y}{z} \end{pmatrix}$$

其中 $f$ 是焦距参数。通过调整相机位置、目标点和旋转角,可以让两个不相连的3D点在屏幕上看起来相邻。

三、Java实现

3.1 3D向量与透视投影

import java.util.*;

/**
 * 纪念碑谷风格的几何与路径规划算法
 * 实现透视投影、不可能图形渲染与图论路径搜索
 */
public class MonumentValleySolver {

    /**
     * 3D点或向量
     */
    static class Vec3 {
        double x, y, z;

        Vec3(double x, double y, double z) {
            this.x = x; this.y = y; this.z = z;
        }

        Vec3 add(Vec3 o) { return new Vec3(x + o.x, y + o.y, z + o.z); }
        Vec3 sub(Vec3 o) { return new Vec3(x - o.x, y - o.y, z - o.z); }
        Vec3 scale(double s) { return new Vec3(x * s, y * s, z * s); }
        double dot(Vec3 o) { return x * o.x + y * o.y + z * o.z; }
        Vec3 cross(Vec3 o) {
            return new Vec3(
                y * o.z - z * o.y,
                z * o.x - x * o.z,
                x * o.y - y * o.x
            );
        }
        double length() { return Math.sqrt(x * x + y * y + z * z); }
        Vec3 normalize() {
            double l = length();
            return l > 0 ? scale(1.0 / l) : this;
        }

        @Override
        public boolean equals(Object o) {
            if (!(o instanceof Vec3)) return false;
            Vec3 v = (Vec3) o;
            return Math.abs(x - v.x) < 1e-6 && 
                   Math.abs(y - v.y) < 1e-6 && 
                   Math.abs(z - v.z) < 1e-6;
        }

        @Override
        public int hashCode() {
            return Objects.hash(x, y, z);
        }
    }

    /**
     * 2D屏幕点
     */
    static class Vec2 {
        double x, y;
        Vec2(double x, double y) { this.x = x; this.y = y; }

        @Override
        public boolean equals(Object o) {
            if (!(o instanceof Vec2)) return false;
            Vec2 v = (Vec2) o;
            return Math.abs(x - v.x) < 1e-6 && Math.abs(y - v.y) < 1e-6;
        }

        @Override
        public int hashCode() { return Objects.hash(x, y); }
    }

    /**
     * 透视投影:将3D点投影到2D屏幕
     * @param point 3D世界坐标点
     * @param cameraPos 相机位置
     * @param focalLength 焦距
     * @return 2D屏幕坐标
     */
    public static Vec2 perspectiveProject(Vec3 point, Vec3 cameraPos, double focalLength) {
        Vec3 rel = point.sub(cameraPos);
        if (Math.abs(rel.z) < 1e-6) return new Vec2(rel.x, rel.y);
        return new Vec2(focalLength * rel.x / rel.z, focalLength * rel.y / rel.z);
    }

3.2 旋转矩阵与相机控制

    /**
     * 围绕X轴的旋转矩阵
     */
    public static Vec3 rotateX(Vec3 v, double angle) {
        double c = Math.cos(angle), s = Math.sin(angle);
        return new Vec3(v.x, c * v.y - s * v.z, s * v.y + c * v.z);
    }

    /**
     * 围绕Y轴的旋转矩阵
     */
    public static Vec3 rotateY(Vec3 v, double angle) {
        double c = Math.cos(angle), s = Math.sin(angle);
        return new Vec3(c * v.x + s * v.z, v.y, -s * v.x + c * v.z);
    }

    /**
     * 围绕Z轴的旋转矩阵
     */
    public static Vec3 rotateZ(Vec3 v, double angle) {
        double c = Math.cos(angle), s = Math.sin(angle);
        return new Vec3(c * v.x - s * v.y, s * v.x + c * v.y, v.z);
    }

    /**
     * 相机(视点)配置
     */
    static class Camera {
        Vec3 position;
        Vec3 target;
        Vec3 up;
        double focalLength;

        Camera(Vec3 pos, Vec3 target, Vec3 up, double f) {
            this.position = pos;
            this.target = target;
            this.up = up;
            this.focalLength = f;
        }

        /**
         * 计算相机正交基
         */
        Vec3[] getBasis() {
            Vec3 forward = target.sub(position).normalize();
            Vec3 right = forward.cross(up).normalize();
            Vec3 newUp = right.cross(forward);
            return new Vec3[]{right, newUp, forward};
        }
    }

3.3 建筑与不可能图形

    /**
     * 建筑物:3D世界中的可移动平台
     */
    static class Building {
        Vec3 position;
        Vec3[] vertices;          // 顶点
        Vec3[] platformPath;      // 平台可到达的路径点
        double rotation;          // 当前旋转角度

        Building(Vec3 position, Vec3[] vertices, Vec3[] path) {
            this.position = position;
            this.vertices = vertices;
            this.platformPath = path;
            this.rotation = 0;
        }

        /**
         * 应用旋转
         */
        void rotate(Vec3 axis, double angle) {
            this.rotation += angle;
            for (int i = 0; i < vertices.length; i++) {
                if (axis.x != 0) vertices[i] = rotateX(vertices[i], angle);
                else if (axis.y != 0) vertices[i] = rotateY(vertices[i], angle);
                else vertices[i] = rotateZ(vertices[i], angle);
            }
            for (int i = 0; i < platformPath.length; i++) {
                if (axis.x != 0) platformPath[i] = rotateX(platformPath[i], angle);
                else if (axis.y != 0) platformPath[i] = rotateY(platformPath[i], angle);
                else platformPath[i] = rotateZ(platformPath[i], angle);
            }
        }
    }

    /**
     * 创建一个彭罗斯三角形效果的不可能图形
     * 三个立方体在视觉上首尾相连,但3D空间中并不真正相连
     */
    public static Building[] createPenroseTriangle(double size) {
        Building[] cubes = new Building[3];
        for (int i = 0; i < 3; i++) {
            double angle = i * Math.PI * 2 / 3;
            Vec3 pos = new Vec3(Math.cos(angle) * size, 0, Math.sin(angle) * size);

            // 每个立方体有8个顶点
            Vec3[] verts = new Vec3[8];
            int idx = 0;
            for (int sx = -1; sx <= 1; sx += 2)
                for (int sy = -1; sy <= 1; sy += 2)
                    for (int sz = -1; sz <= 1; sz += 2) {
                        verts[idx++] = new Vec3(sx * size/2, sy * size/2, sz * size/2);
                    }

            // 平台路径:绕立方体顶面一周
            Vec3[] path = {
                new Vec3(-size/2, size/2 + 0.1, -size/2),
                new Vec3(size/2, size/2 + 0.1, -size/2),
                new Vec3(size/2, size/2 + 0.1, size/2),
                new Vec3(-size/2, size/2 + 0.1, size/2)
            };

            cubes[i] = new Building(pos, verts, path);
        }
        return cubes;
    }

3.4 路径规划算法

    /**
     * 使用BFS/DFS在3D空间中寻找从起点到终点的路径
     * 关键:路径必须考虑建筑物旋转和视觉欺骗
     */
    public static List<Vec3> findPath(Vec3 start, Vec3 end, 
                                       List<Building> buildings,
                                       Camera camera) {
        // 构建图:每个建筑物的每个路径点作为一个节点
        // 如果两个路径点在3D空间中真实可达,则连边
        Map<Vec3, List<Vec3>> graph = new HashMap<>();

        for (Building b : buildings) {
            for (Vec3 p : b.platformPath) {
                graph.computeIfAbsent(p, k -> new ArrayList<>());
            }
        }

        // 添加起点和终点
        graph.put(start, new ArrayList<>());
        graph.put(end, new ArrayList<>());

        // 真实几何连接:仅当两个平台真实物理连接时建边
        for (Building b1 : buildings) {
            for (Building b2 : buildings) {
                if (b1 == b2) continue;
                for (Vec3 p1 : b1.platformPath) {
                    for (Vec3 p2 : b2.platformPath) {
                        if (isPhysicallyConnected(p1, p2, b1, b2)) {
                            graph.get(p1).add(p2);
                            graph.get(p2).add(p1);
                        }
                    }
                }
            }
        }

        // 检查起点/终点到平台的连接
        for (Building b : buildings) {
            for (Vec3 p : b.platformPath) {
                if (isPhysicallyConnected(start, p, null, b)) {
                    graph.get(start).add(p);
                    graph.get(p).add(start);
                }
                if (isPhysicallyConnected(end, p, null, b)) {
                    graph.get(end).add(p);
                    graph.get(p).add(end);
                }
            }
        }

        // BFS搜索
        return bfs(start, end, graph);
    }

    /**
     * 判断两个平台是否在3D空间中真实物理连接
     * 与视觉欺骗无关,仅看绝对位置
     */
    private static boolean isPhysicallyConnected(Vec3 p1, Vec3 p2, 
                                                  Building b1, Building b2) {
        double distance = p1.sub(p2).length();
        return distance < 0.5;  // 距离小于0.5个单位视为可通行
    }

    private static List<Vec3> bfs(Vec3 start, Vec3 end, 
                                    Map<Vec3, List<Vec3>> graph) {
        Queue<Vec3> queue = new LinkedList<>();
        Map<Vec3, Vec3> parent = new HashMap<>();
        Set<Vec3> visited = new HashSet<>();

        queue.offer(start);
        visited.add(start);

        while (!queue.isEmpty()) {
            Vec3 cur = queue.poll();
            if (cur.equals(end)) {
                // 重建路径
                List<Vec3> path = new ArrayList<>();
                while (cur != null) {
                    path.add(0, cur);
                    cur = parent.get(cur);
                }
                return path;
            }
            for (Vec3 next : graph.getOrDefault(cur, Collections.emptyList())) {
                if (!visited.contains(next)) {
                    visited.add(next);
                    parent.put(next, cur);
                    queue.offer(next);
                }
            }
        }
        return Collections.emptyList();
    }

    /**
     * 视觉欺骗检测:判断两个点是否在屏幕上看起来相邻
     * 这用于游戏中的"魔法移动"判定
     */
    public static boolean looksAdjacent(Vec3 p1, Vec3 p2, Camera camera, double threshold) {
        Vec2 screen1 = perspectiveProject(p1, camera.position, camera.focalLength);
        Vec2 screen2 = perspectiveProject(p2, camera.position, camera.focalLength);
        double screenDist = Math.sqrt(
            Math.pow(screen1.x - screen2.x, 2) + 
            Math.pow(screen1.y - screen2.y, 2)
        );
        double worldDist = p1.sub(p2).length();
        // 屏幕距离小但3D距离大 = 视觉欺骗
        return screenDist < threshold;
    }

3.5 完整游戏逻辑

    /**
     * 完整游戏状态
     */
    static class GameState {
        Vec3 playerPosition;
        List<Building> buildings;
        Vec3 goalPosition;
        Camera camera;

        GameState(Vec3 player, Vec3 goal, List<Building> b, Camera c) {
            playerPosition = player;
            goalPosition = goal;
            buildings = b;
            camera = c;
        }

        /**
         * 玩家移动:基于视觉欺骗
         * 玩家点击屏幕上某点,游戏根据视觉位置决定移动
         */
        public void movePlayer(Vec2 screenTarget) {
            // 找到屏幕上看起来最接近 target 的点
            Vec3 closest = null;
            double minDist = Double.MAX_VALUE;

            for (Building b : buildings) {
                for (Vec3 p : b.platformPath) {
                    Vec2 screenP = perspectiveProject(p, camera.position, camera.focalLength);
                    double dist = Math.sqrt(
                        Math.pow(screenP.x - screenTarget.x, 2) + 
                        Math.pow(screenP.y - screenTarget.y, 2)
                    );
                    if (dist < minDist) {
                        minDist = dist;
                        closest = p;
                    }
                }
            }

            if (closest != null) {
                // 真实移动前验证3D路径可行性
                if (canMoveIn3D(playerPosition, closest)) {
                    playerPosition = closest;
                }
                // 否则使用"魔法移动":通过视觉欺骗直接跳转
                else if (looksAdjacent(playerPosition, closest, camera, 0.5)) {
                    playerPosition = closest;  // 不可能移动!
                }
            }
        }

        private boolean canMoveIn3D(Vec3 from, Vec3 to) {
            // 简化的3D路径检查:直线距离 < 阈值
            return from.sub(to).length() < 1.0;
        }

        public boolean isGoalReached() {
            return playerPosition.sub(goalPosition).length() < 0.3;
        }
    }

    public static void main(String[] args) {
        System.out.println("=== 纪念碑谷算法演示 ===\n");

        // 创建不可能图形
        Building[] penrose = createPenroseTriangle(2.0);
        List<Building> buildings = Arrays.asList(penrose);

        // 创建相机
        Vec3 cameraPos = new Vec3(0, 3, 8);
        Vec3 target = new Vec3(0, 0, 0);
        Vec3 up = new Vec3(0, 1, 0);
        Camera camera = new Camera(cameraPos, target, up, 1.5);

        // 创建游戏状态
        Vec3 player = new Vec3(0, 1.1, -1);
        Vec3 goal = new Vec3(0, 1.1, 1);
        GameState game = new GameState(player, goal, buildings, camera);

        System.out.println("初始玩家位置:" + game.playerPosition);
        System.out.println("目标位置:" + game.goalPosition);

        // 演示透视投影
        System.out.println("\n=== 透视投影示例 ===");
        Vec3 worldPoint = new Vec3(0, 0, 5);
        Vec2 screenPoint = perspectiveProject(worldPoint, cameraPos, 1.5);
        System.out.println("3D点 " + worldPoint + " -> 屏幕点 " + screenPoint);

        // 演示视觉欺骗
        System.out.println("\n=== 视觉欺骗检测 ===");
        Vec3 p1 = new Vec3(-2, 0, 3);
        Vec3 p2 = new Vec3(2, 0, 7);
        System.out.println("3D距离:" + p1.sub(p2).length());
        System.out.println("看起来相邻?" + looksAdjacent(p1, p2, camera, 0.5));

        // 路径规划
        System.out.println("\n=== 路径规划 ===");
        List<Vec3> path = findPath(player, goal, buildings, camera);
        System.out.println("找到路径,长度:" + path.size());
        for (int i = 0; i < path.size(); i++) {
            System.out.println("  步骤 " + (i+1) + ": " + path.get(i));
        }
    }
}

四、复杂度分析

操作 时间复杂度 空间复杂度
透视投影 $O(1)$ $O(1)$
旋转矩阵 $O(1)$ $O(1)$
路径搜索(BFS) $O(V + E)$ $O(V + E)$
视觉欺骗检测 $O(1)$ $O(1)$

其中 $V$ 为路径点数,$E$ 为物理连接数。

五、算法扩展

  • 四元数旋转:避免万向节锁,实现平滑动画
  • 光线追踪:增强视觉欺骗的真实性
  • 真实物理引擎:实现艾达的坠落、爬坡
  • 可访问性设计:为色盲玩家提供替代方案

纪念碑谷的算法核心在于 “3D世界与2D视觉的不一致”。掌握这个思想,你就可以构建出无限多富有创意的不可能图形谜题。