金三银四,一个面试官连连夸赞的个人网页技术分享

不知道大家有没有同样的感觉:

今年的 "金三银四",似乎没有想象中那么热闹。 尤其是 前端岗位,不少公司都在收缩,机会明显少了很多。

我最近也参加了几场大厂面试(我面的是 AI 全栈开发岗)。 有一件事让我挺意外的。

某度的面试官在面试完后加微信聊天的时候说:

"刚看到你简历上的个人网站时,就明显感觉到你是一个有技术追求的同学,还让 hr 赶快联系一下面试。"

然后就把这个网站发到分享到小红书上,b站上去了(之前看过类似的效果,特地复刻了一版),反响很不错,所以特此非常以下里面的技术细节。

效果如下:

点击跳转在线地址

同时,欢迎大家关注我的微信公众号:ai超级个人,会有更多的炫酷网页分享

这篇文章会稍微偏技术一些。

如果你:

  • 对网页动效感兴趣
  • 或者几乎没有 Three.js 基础
  • 甚至不太懂 3D

这些思路依然很有价值。

尤其是在 你让 AI 帮你调试代码、改造项目的时候,理解这些结构会非常有帮助。

如果你想系统学习 Three.js,我只推荐一套教程:

Three.js Journey(点击跳转 B 站)

这是我心中目前全球范围内,从 0 到 1 最完美的 Three.js 课程。说实话,市面上很多所谓的基础教程,且不说是否存在"割韭菜"的行为,单是乏味的教学逻辑就在浪费你的学习生命。

好了,回到正文。今天我们要深度拆解上面网站效果,以及解决如下三个核心问题:

1. 空间定位:如何在网页中调试模型位置?

在 3D 世界里,任何模型都有它的坐标。以下面这个电脑模型为例:

模型默认被放置在原点 <math xmlns="http://www.w3.org/1998/Math/MathML"> ( 0 , 0 , 0 ) (0, 0, 0) </math>(0,0,0),这通常没问题。但真正的痛点在于:摄像机应该架在哪?

打个比方,这就好比现实中的人像摄影:

  • 被拍的人 → 相当于 3D 模型
  • 摄影师 → 相当于 Camera(相机)

模型在那不动,但摄影师的位置(Position)和对焦的方向(LookAt)决定了最终的画面。

而且我们能不能像在 Blender(知名的 3D 图形软件)里一样,在网页端也能直观地旋转、调整远近,从而找到那个最完美的视觉角度?如下图是 blender 的界面:

2. 跨次元融合:如何将真实网页嵌入 3D 场景?

请看下图:

在这台 3D 电脑模型的屏幕中央,其实嵌套了一个真实的网页(前端术语叫 iframe)。

所以问题来了,在 three.js 中如何嵌套一个别的网站的网页呢?

3. 精准对位:如何调试 iframe 的 3D 坐标?

这是上一个问题的延伸。iframe(网页) 作为一个平面,在 3D 空间中同样拥有自己的坐标和旋转参数。但问题是:

你很难凭直觉盲猜出电脑模型那块屏幕的精确数值。

因此,我们需要一套可视化的调试界面 。然后配合我们第一步提到的工具,让我们可以在页面上手动微调 iframe 的位置,直到它与模型屏幕完美贴合。

最后,直接将调试好的坐标参数"写死"在代码里就能保证初始化电脑模型和网页都在合适的坐标上。

接下来,我们一步一步,从 0 到 1 实现这个过程:

网页中调整相机位置小技巧

初始的时候,我们假设有如下代码(精简后的 demo )。代码主要做的是将电脑的模型贴图加载进来。

如果你有不了解的代码块可以借助 ai 了解详细信息,因为已经是最基础的 three.js 代码,如果缺乏必要的基础,建议学习上面的教程。

注:代码使用了 react 框架,你也可以让 ai 改造为你的熟悉的技术栈,例如 vue 或者 html:

javascript 复制代码
import * as THREE from "three";
import { GLTFLoader } from "three/addons/loaders/GLTFLoader.js";

export interface Computer3DConfig {
  container: HTMLElement;
  modelPath: string;
  texturePath: string;
  modelScale?: number;
}

export class Computer3D {
  private scene: THREE.Scene = new THREE.Scene();
  private camera: THREE.PerspectiveCamera;
  private renderer: THREE.WebGLRenderer;
  private container: HTMLElement;
  private config: Computer3DConfig;

  constructor(config: Computer3DConfig) {
    this.config = { ...config };
    this.container = config.container;

    // 1. 初始化相机
    const aspect = window.innerWidth / window.innerHeight;
    this.camera = new THREE.PerspectiveCamera(35, aspect, 10, 100000);

    // 2. 初始化渲染器 (WebGL)
    this.renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
    this.renderer.setSize(window.innerWidth, window.innerHeight);
    this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
    this.renderer.domElement.style.position = "absolute";
    this.renderer.domElement.style.top = "0";
    this.renderer.domElement.style.zIndex = "1";
    this.container.appendChild(this.renderer.domElement);

    this.render();
  }

  // 加载模型与贴图
  public async load(): Promise<void> {
    const loader = new GLTFLoader();
    const textureLoader = new THREE.TextureLoader();

    // 并行加载模型和贴图
    const [gltf, texture] = await Promise.all([
      loader.loadAsync(this.config.modelPath),
      textureLoader.loadAsync(this.config.texturePath),
    ]);

    // 贴图配置
    texture.flipY = false;
    texture.colorSpace = THREE.SRGBColorSpace;
    const material = new THREE.MeshBasicMaterial({ map: texture });

    // 遍历模型应用材质
    gltf.scene.traverse((child: any) => {
      if (child instanceof THREE.Mesh) {
        child.material = material;
      }
    });

    this.scene.add(gltf.scene);
  }

  // 渲染循环
  private render(): void {
    requestAnimationFrame(this.render.bind(this));
    this.renderer.render(this.scene, this.camera);
  }

  // 处理窗口大小调整(建议添加)
  public onWindowResize(): void {
    const width = window.innerWidth;
    const height = window.innerHeight;
    this.camera.aspect = width / height;
    this.camera.updateProjectionMatrix();
    this.renderer.setSize(width, height);
  }
}

export default Computer3D;

避坑指南:为什么初始化你的 3D 世界是一团黑?

很多同学在加载代码后,满怀期待地打开页面,结果发现是一片漆黑。例如我们上面的代码。

别担心,并不是模型消失了,只是你正"站在模型肚子里"!(模型内部)默认情况下,相机的初始坐标在 (0, 0, 0)。而模型加载进来也通常在原点。

这种"合二为一"的状态让你什么也看不见。为了解决这个问题,我们需要像真正的摄影师一样,完成以下三个层层递进的步骤。

首先就是让相机能够完整的看到电脑模型:

第一步:开启"自动对焦",给模型一个完美的全身照

首先模型的大小是不可控的,有的只有几厘米,有的却有几百米。

所以我们需要一个通用的"自动对焦"函数,让相机自动根据模型的大小调整距离,起码能看清楚模型的全身,然后再后续微调相机位置。

核心逻辑: 用一个隐形的方框把模型包起来(Box3),测量它的尺寸,然后把相机推到足够远的地方。

JAVASCRIPT 复制代码
/**
 * ✅ 自动对焦:不仅要移相机,还要移控制器的目标点
 */
private autoFitCamera(object: THREE.Object3D): void {
  // 1. 计算模型的包围盒
  const box = new THREE.Box3().setFromObject(object);
  const size = box.getSize(new THREE.Vector3());   // 获取模型长宽高
  const center = box.getCenter(new THREE.Vector3()); // 获取模型的中心点

  // 2. 根据模型大小计算相机距离
  const maxDim = Math.max(size.x, size.y, size.z);
  const fov = this.camera.fov * (Math.PI / 180); // 视角转弧度
  // 数学公式:距离 = 对边 / tan(角度)
  const cameraDistance = Math.abs(maxDim / 2 / Math.tan(fov / 2)) * 1.5;

  // 3. 移动相机位置:稍微偏一点,让画面有立体感
  this.camera.position.set(
    center.x + maxDim * 0.2,
    center.y + maxDim * 0.3,
    center.z + cameraDistance,
  );

  // 4. 🔥 关键:让相机不仅"站得远",还要"盯着看"
  // 在使用控制器时,必须更新 target 才能真正改变视线方向
  this.controls.target.copy(center);
  this.controls.update();
}

第二步:引入"上帝视角",让场景动起来

有了对焦还不够,如果你想 360 度观察模型,就需要 OrbitControls(轨道控制器)。它能让你的鼠标变成相机的"推进器"和"转盘"。

也就是有了 OrbitControls,我们就可以动态调整相机的位置,让相机上下左右移动,并且旋转相机视角。

代码集成方案:

javascript 复制代码
// 1. 引入辅助组件
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";

// 2. 在类里面定义控制器属性
private controls: OrbitControls;

// 3. 在 constructor (构造函数) 中初始化
// 这里的第二个参数 renderer.domElement 很重要,它决定了鼠标在哪里滑有效
this.controls = new OrbitControls(this.camera, this.renderer.domElement);
this.controls.enableDamping = true; // 开启阻尼(手感更丝滑,不会突然停住)
this.controls.dampingFactor = 0.05;

// 4. 重点:在 render 循环中每一帧都要更新它
private render(): void {
  requestAnimationFrame(this.render.bind(this));
  
  // 只有更新了 controls,你的拖拽和阻尼效果才会生效
  this.controls.update(); 
  this.renderer.render(this.scene, this.camera);
}

第三步:搭建"导演工作站",用键盘寻找黄金视角

上面 OrbitControls 虽然能动态调整相机位置,但是毕竟鼠标不好控制,我们还需要增加一些精细的微调方法。

我们采取的是通过键盘微调相机位置,找到感觉最好的那一刻,记录下坐标。也就是键盘一些快捷键能小范围的移动相机视角。

功能说明:

  • 方向键和 W/S:可以在 3D 空间的前后左右上下移动。

  • P 键:在控制台打印当前相机的"黄金坐标"。

javascript 复制代码
private setupDebugControls(): void {
  // 辅助线:添加网格和坐标轴,防止在 3D 空间迷失方向
  const gridHelper = new THREE.GridHelper(10000, 100);
  const axesHelper = new THREE.AxesHelper(5000);
  this.scene.add(gridHelper, axesHelper);

  window.addEventListener("keydown", (e) => {
    const step = 100; // 每次按键移动的距离
    const moveMap: Record<string, [number, number, number]> = {
      ArrowUp: [0, step, 0],    // 向上
      ArrowDown: [0, -step, 0], // 向下
      ArrowLeft: [-step, 0, 0], // 向左
      ArrowRight: [step, 0, 0], // 向右
      w: [0, 0, -step],         // 向前
      s: [0, 0, step],          // 向后
    };

    if (moveMap[e.key]) {
      const [x, y, z] = moveMap[e.key];
      this.camera.position.addScaledVector(new THREE.Vector3(x, y, z), 1);
    }

    // ✅ 导出视角:当你调到满意的角度时,按 P 打印参数
    if (e.key === "p") {
      console.log(`--- 记录当前黄金视角 ---`);
      console.log(`相机位置: .set(${this.camera.position.x}, ${this.camera.position.y}, ${this.camera.position.z})`);
      console.log(`盯着看的目标点: .set(${this.controls.target.x}, ${this.controls.target.y}, ${this.controls.target.z})`);
    }
  });
}

综上所述,大概就能模拟一个 3D 模型软件的 Camera 视角了。

将网页加入到 three.js 中

这一章节我们进入最酷的部分:让你的 3D 电脑真正"联网"(嵌入网页)。

在普通的 3D 场景中,物体通常只是死板的几何体。但 Three.js 提供了一个强大的"传送门" ------ CSS3DObject。

它能把真实的 HTML 元素(如 div、iframe、video)直接塞进 3D 空间,让网页像贴纸一样贴在模型的屏幕上,且依然保持可点击、可交互。

要实现这个效果,我们需要同时运行两个渲染器:

  • WebGLRenderer (底层):负责渲染 3D 模型(电脑外壳)。

  • CSS3DRenderer (顶层):负责将 HTML 元素通过 CSS3 矩阵变换,投影到 3D 空间中。

我们将这两个渲染器的画布重叠在一起,并让它们共享同一个相机(Camera)。这样当你旋转镜头时,网页和模型就会同步运动,看起来就像网页长在模型上一样。

最小实现 Demo 代码: 以下是整合了模型加载与 iframe 屏幕渲染的完整代码:

javascript 复制代码
import * as THREE from "three";
import { GLTFLoader } from "three/addons/loaders/GLTFLoader.js";
import { OrbitControls } from "three/addons/controls/OrbitControls.js";
import {
  CSS3DRenderer,
  CSS3DObject,
} from "three/addons/renderers/CSS3DRenderer.js";

export interface Computer3DConfig {
  container: HTMLElement;
  modelPath: string;
  texturePath: string;
  screenUrl: string; // 注入 iframe 的网址
  modelScale?: number;
}

export class Computer3D {
  private scene: THREE.Scene = new THREE.Scene(); // WebGL 场景
  private cssScene: THREE.Scene = new THREE.Scene(); // CSS3D 专用场景
  private camera: THREE.PerspectiveCamera;
  private renderer: THREE.WebGLRenderer;
  private cssRenderer: CSS3DRenderer;
  private controls: OrbitControls;
  private container: HTMLElement;
  private config: Computer3DConfig;

  constructor(config: Computer3DConfig) {
    this.config = { modelScale: 4200, ...config };
    this.container = config.container;

    const width = window.innerWidth;
    const height = window.innerHeight;

    // 1. 初始化相机
    this.camera = new THREE.PerspectiveCamera(35, width / height, 10, 100000);
    this.camera.position.set(930, 600, 4500);

    // 2. 初始化 WebGL 渲染器 (渲染底层模型)
    this.renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
    this.renderer.setSize(width, height);
    this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
    this.renderer.domElement.style.position = "absolute";
    this.renderer.domElement.style.top = "0";
    this.renderer.domElement.style.zIndex = "1"; // 确保在底层
    this.container.appendChild(this.renderer.domElement);

    // 3. 初始化 CSS3D 渲染器 (渲染顶层网页)
    this.cssRenderer = new CSS3DRenderer();
    this.cssRenderer.setSize(width, height);
    this.cssRenderer.domElement.style.position = "absolute";
    this.cssRenderer.domElement.style.top = "0";
    this.cssRenderer.domElement.style.zIndex = "2"; // 盖在模型上面
    this.container.appendChild(this.cssRenderer.domElement);

    // 4. 初始化轨道控制器
    // 💡 注意:事件监听要绑定在最上层的 cssRenderer 元素上,否则会被遮挡
    this.controls = new OrbitControls(this.camera, this.cssRenderer.domElement);
    this.controls.enableDamping = true;
    this.controls.target.set(925, 310, -300);

    this.render();
    window.addEventListener("resize", () => this.onWindowResize());
  }

  // 加载模型与贴图
  public async load(): Promise<void> {
    const loader = new GLTFLoader();
    const textureLoader = new THREE.TextureLoader();

    const [gltf, texture] = await Promise.all([
      loader.loadAsync(this.config.modelPath),
      textureLoader.loadAsync(this.config.texturePath),
    ]);

    texture.flipY = false;
    texture.colorSpace = THREE.SRGBColorSpace;
    const material = new THREE.MeshBasicMaterial({ map: texture });

    gltf.scene.traverse((child: any) => {
      if (child instanceof THREE.Mesh) {
        child.scale.setScalar(this.config.modelScale!);
        child.material = material;
      }
    });

    this.scene.add(gltf.scene);
    
    // 🔥 模型加载完后,开始在 3D 空间"插"一个网页
    this.initIframeScreen();
  }

  private initIframeScreen(): void {
    // 设定网页的逻辑分辨率(相当于显示器的分辨率)
    const SCREEN_W = 1480;
    const SCREEN_H = 1100;

    // 创建原生 iframe
    const iframe = document.createElement("iframe");
    iframe.src = this.config.screenUrl;
    iframe.style.width = `${SCREEN_W}px`;
    iframe.style.height = `${SCREEN_H}px`;
    iframe.style.border = "none";
    iframe.style.backgroundColor = "#000";

    // 包装成 3D 对象
    const cssObject = new CSS3DObject(iframe);
    
    /**
     * 🛠 坐标微调:
     * 这是最关键也最耗时的一步。你需要根据模型屏幕的具体位置,
     * 反复调整 position 和 rotation,直到 iframe 严丝合缝地贴在模型框里。
     * 下一小节会有微调的方法
     */
    cssObject.position.set(900, 458, 765); 
    cssObject.rotation.x = -1; // 配合模型显示器的后仰角度

    this.cssScene.add(cssObject);
  }

  private onWindowResize(): void {
    const w = window.innerWidth;
    const h = window.innerHeight;
    this.camera.aspect = w / h;
    this.camera.updateProjectionMatrix();
    this.renderer.setSize(w, h);
    this.cssRenderer.setSize(w, h);
  }

  private render(): void {
    requestAnimationFrame(this.render.bind(this));
    this.controls.update();
    
    // 💡 必须同时渲染 WebGL 和 CSS3D 两个场景
    this.renderer.render(this.scene, this.camera);
    this.cssRenderer.render(this.cssScene, this.camera);
  }
}

闭坑指南:

  • 关于 zIndex 的博弈:

我们将 cssRenderer 的 zIndex 设为 2(放在模型上面, 模型的 zIndex 是 1,谁的 zIndex 大,谁就在上面)。所以我们需要我们的网页(用 cssRenderer 渲染)层级更高。

这样你才能直接在 3D 场景里点击网页上的按钮、滑动网页。如果 WebGL 渲染器在上面,网页就只能看不能摸了。

  • "穿模"与遮挡:

现在的实现方式有一个小缺陷:因为 cssRenderer 永远在 WebGL 模型之上,所以即便你把电脑转到背面,网页依然会"穿过"模型显示在最前面。

因为 cssRender 渲染的是 HTML 元素, 又因为 html 支持 css 中的 backfaceVisibility 属性可以隐藏背面不可见。

所以自然给 cssRender 元素增加 backfaceVisibility = true 即可解决穿模问题。

如何让网页严丝合缝地"贴"到模型屏幕上?

接下来就是最后一个问题了,如何把网页贴在电脑模型的屏幕上。介绍一种常见的微调方法。

我们引入调试利器:Tweakpane。

有类似功能的调试库有很多,如常见的有 dat.GUI,lil-gui 等等,我们使用的是 ai 建议的 Tweakpane 库,并且调试代码完全可以交给 ai 来写。

调试的界面如下:

这部分代码 ai 实现很容易,你可以用以下 prompt 生成对应代码即可,如下:

scss 复制代码
"我正在使用 Three.js 和 CSS3DObject。请帮我引入 Tweakpane 库,为我的 cssObject 创建一个调试面板。
需求:

1. 添加 position (x,y,z) 的调试滑块,范围设置在 -2000 到 2000。

2. 添加 rotation (x,y,z) 的调试滑块,范围是 -Math.PI 到 Math.PI。

3. 添加一个 'Export' 按钮,点击后能在控制台直接打印出当前的 position.set(x,y,z) 和 rotation.set(x,y,z) 代码,方便我复制固定死。请给我完整的 TypeScript 代码片段。"

通过上面的学习,我们发现 3D 网页开发不仅仅是写代码,更是一场关于"寻找最佳视角"的艺术。

最后,欢迎大家加群一起讨论全栈 AI 的实践,讨论酷炫动画的实现。我们下期再见!

相关推荐
兆子龙2 小时前
Vite 插件系统与构建流水线源码解析:从 Rollup 插件到 HMR
前端
Trouvaille ~2 小时前
【贪心算法】专题(六):降维打击与错位重构的终极收官
c++·算法·leetcode·面试·贪心算法·重构·蓝桥杯
代码老中医2 小时前
Node_modules 比黑洞还重,我们的硬盘到底做错了什么?
前端
兆子龙2 小时前
Vue 3 响应式系统 Reactivity 源码深度解析:从 ref 到 effect 的完整链路
前端
Smoothcloud_润云2 小时前
GORM 事务管理与 Repository 模式完整指南
前端·数据库·代码规范
兆子龙2 小时前
Turborepo 与 Monorepo 任务调度源码解析:从 DAG 到增量构建
前端·架构
程序员爱钓鱼2 小时前
Go字符串与数值转换核心库: strconv深度解析
后端·面试·go
兆子龙2 小时前
React 18 并发与 Reconciler 源码解析:Fiber、调度器与可中断渲染
前端
张一凡932 小时前
easy-model 领域驱动实践
前端·react.js