打造一面“玻璃”

250 阅读12分钟

有些灵感,是雨天带来的。

比如那天下午,窗外阴沉,玻璃窗上映着模糊的倒影,水珠拖曳出一道道像雾又像光的纹理。我盯着这块玻璃看了很久,突然心想:我能不能用代码还原这种“虚拟玻璃”的感觉?

就这样,一段技术与设计交织的旅程就此开始。


🎯 项目的想法从哪来?

我一直对交互式视觉效果情有独钟。从文字打字机特效到 Canvas 粒子爆炸,这些细节常常能让用户的情绪波动产生共鸣。而玻璃反射效果,既含蓄又浪漫,不是炫技,却能极大提升网页的质感。

这也是 Reflect Glass 项目的起点:用浏览器技术模拟一个逼真的虚拟玻璃,能实时反射用户的影像、带有细腻的模糊、光晕、倒影。

于是,我给自己定下几个目标:

  • 能捕捉摄像头并实时显示用户倒影;
  • 呈现玻璃感:柔光、模糊、阴影、倒影、边框;
  • 允许截图,保存这一瞬间的虚拟“自拍”;
  • 响应式设计,兼容移动端浏览;
  • 保持足够性能,在低配设备上也能运行流畅。

🧩 技术选型是这样定下来的

做浏览器里的反光玻璃,我不打算用 WebGL。一方面 WebGL 对新手设备要求高,另一方面效果可控性不如 Canvas + CSS 来得清晰。我想在既有的前端体系里,完成一场自我挑战。

因此我选择了这些技术作为项目基石:

  • Vue 3 + Vite:开发体验好,响应式逻辑清晰,组合式 API 可复用性强;
  • Canvas API:捕捉摄像头图像,实时绘制、变换、模糊处理;
  • WebRTC:通过 getUserMedia 获取摄像头流;
  • CSS backdrop-filter / filter:为玻璃添加背景模糊、光照效果;
  • TypeScript:整个项目都用 TS 编写,类型安全、补全友好;
  • 组合式函数 Composables:逻辑抽离,提升可读性与可维护性。

🧱 架构与流程:从影像到反光

项目的基本渲染流程如下:

在这里插入图片描述

你可以理解为一个三层架构:

  1. Camera 层:接入用户设备,捕捉视频流,传入 Canvas;
  2. Effect 层:对视频帧进行水平翻转、模糊、透明度调整;
  3. Scene 层:展示最终效果,组合出类似现实中的玻璃窗场景。

接下来我就从这三个方面,一步步展开讲讲我是怎么实现的。


🧊 构建场景:玻璃、墙面与光影

这个项目的前端 UI 其实非常“克制”:没有多余元素,一个大大的玻璃框就是核心。

为了营造“玻璃装在墙上的感觉”,我设计了这样一个组件层次:

<template>
  <div class="scene-container">
    <div class="wall">
      <div class="wall-texture"></div>
    </div>

    <div class="glass-frame">
      <div class="glass-surface">
        <slot></slot>
      </div>
    </div>
  </div>
</template>

🧱 墙面设计

墙面使用纯 CSS 渐变与背景图案模拟出一种质感:

.wall-texture {
  background-image:
    linear-gradient(45deg, #ccc 25%, transparent 25%),
    linear-gradient(-45deg, #ccc 25%, transparent 25%);
  background-size: 20px 20px;
  opacity: 0.05;
}

这样在大屏下不会显得单调,又不至于分散焦点。

🪟 玻璃框架设计

玻璃使用了 backdrop-filter 和阴影模拟真实玻璃的模糊、透明感:

.glass-frame {
  border: 16px solid #2c3e50;
  border-radius: 8px;
  box-shadow:
    0 0 30px rgba(0, 0, 0, 0.2),
    inset 0 0 15px rgba(255, 255, 255, 0.05);
}

.glass-surface {
  background: rgba(255, 255, 255, 0.05);
  backdrop-filter: blur(3px);
  position: relative;
  overflow: hidden;
}

这样的设计有几个好处:

  • 保持轻量,不依赖图片或贴图;
  • 支持响应式缩放;
  • 整体调性一致,符合现代 UI 审美。

📸 摄像头接入与帧捕捉

核心组件 CameraView.vue 负责调用用户摄像头,并将视频源渲染到隐藏的 <video><canvas> 中。

开始摄像头访问

const startCamera = async () => {
  const stream = await navigator.mediaDevices.getUserMedia({
    video: { facingMode: 'user' }
  });
  videoRef.value!.srcObject = stream;
  startRenderLoop();
};

这一段代码会触发浏览器的摄像头授权弹窗,如果用户同意,返回的 stream 会绑定到 video 元素的 srcObject 属性。

为了让摄像头帧能被进一步处理,我不直接显示 video,而是把它绘制到 Canvas 中。


🪞 图像处理:反光与镜像渲染

这个部分是整个项目的精华。我封装了一个组合函数 useGlassEffect.ts,专门用于处理图像的“玻璃反射效果”。

水平翻转的关键

通过 canvas 的 context.scale(-1, 1) 实现“镜面效果”:

ctx.save();
ctx.scale(-1, 1);
ctx.drawImage(video, -canvas.width, 0);
ctx.restore();

注意 drawImage 的 x 坐标必须为负值,否则图像会绘制到画布外面。

添加模糊与透明度

ctx.filter = 'blur(2px) brightness(1.05)';
ctx.globalAlpha = 0.6;

这样就做出了那种“若隐若现”的反射效果。光线从人物轮廓周围淡出,看起来就像真的站在玻璃前。


🧪 动态调试 + 预设效果

为了让用户体验更灵活,我还加了几套效果预设,比如:

  • 清晰玻璃(透明度低,模糊小);
  • 雾化玻璃(模糊更重);
  • 色调玻璃(带颜色滤镜)。
const GLASS_PRESETS = {
  clear: { reflectionOpacity: 0.5, glassBlur: 2, glassColor: 'rgba(255,255,255,0.05)' },
  foggy: { reflectionOpacity: 0.7, glassBlur: 5, glassColor: 'rgba(240,240,240,0.1)' },
  tinted: { reflectionOpacity: 0.6, glassBlur: 3, glassColor: 'rgba(0,128,255,0.05)' }
};

我用 ref + computed 来动态响应这些参数的变化,非常自然:

const activeEffect = computed(() => {
  return {
    filter: `blur(${customEffect.value.glassBlur}px)`,
    opacity: customEffect.value.reflectionOpacity,
    backgroundColor: customEffect.value.glassColor
  };
});

这样即便以后我接入用户滑块调整模糊程度、透明度,也无需改动 Canvas 逻辑,直接绑定即可。


📷 截图逻辑:记录“玻璃里的我”

实现“拍照”功能,其实并不复杂,但我希望它尽可能丝滑。

当用户点击“📷”图标时,我们抓取当前效果 Canvas 的内容,并下载为 PNG 文件。

按钮 UI 设计

按钮采用浮动样式固定在右下角,使用了现代圆形按钮设计:

<template>
  <button class="capture-button" @click="captureImage">📷</button>
</template>
.capture-button {
  position: absolute;
  bottom: 32px;
  right: 32px;
  background-color: rgba(0, 0, 0, 0.6);
  color: white;
  border: none;
  border-radius: 50%;
  width: 64px;
  height: 64px;
  font-size: 24px;
  box-shadow: 0 0 10px rgba(0,0,0,0.3);
  cursor: pointer;
  transition: background-color 0.2s;
}

.capture-button:hover {
  background-color: rgba(0, 0, 0, 0.8);
}

实现截图逻辑

截图只需要从 Canvas 中提取图像数据:

const captureImage = () => {
  const canvas = document.querySelector('.reflection-canvas') as HTMLCanvasElement;
  if (canvas) {
    const link = document.createElement('a');
    link.download = `reflect-glass-${new Date().toISOString()}.png`;
    link.href = canvas.toDataURL('image/png');
    link.click();
  }
};

通过 canvas.toDataURL 拿到图像数据,再触发一个 a 标签模拟点击,完成下载。

这个功能虽然短短几行代码,但它将虚拟空间变成了可以保存的“记忆”,对用户来说具有情绪意义。


📱 响应式设计与设备兼容

在设计之初我就设想过,很多用户可能会在手机上试这个应用,所以必须兼容各种设备。

Media Query 优化布局

我加入了一套简洁的响应式规则:

@media (max-width: 768px) {
  .glass-frame {
    width: 90vw;
    height: 60vh;
    border-width: 10px;
  }

  .capture-button {
    width: 48px;
    height: 48px;
    font-size: 18px;
    bottom: 16px;
    right: 16px;
  }
}

这样在手机上也能保持优雅布局,不至于控件重叠或被挤压。


🛠 性能与兼容性优化

虽然功能实现上已经可用,但我发现性能对用户体验影响极大,尤其在中低端 Android 手机上,最初版本会发烫掉帧。

我采取了几种方法优化性能:

🌡 降低帧率与渲染频率

针对不同设备,动态控制帧率:

const isMobile = ref(window.innerWidth < 768);
const targetFPS = computed(() => (isMobile.value ? 20 : 60));

const renderLoop = () => {
  const now = performance.now();
  const delta = now - lastRenderTime.value;

  if (delta > 1000 / targetFPS.value) {
    drawToCanvas(); // 只在需要时重绘
    lastRenderTime.value = now;
  }

  requestAnimationFrame(renderLoop);
};

这样在移动端就不会频繁全速刷新画面,节能且稳定。

🧱 Canvas 操作避免重排

每次绘制前复用已有画布尺寸,防止多余 resize:

sourceCanvas.width = video.videoWidth;
sourceCanvas.height = video.videoHeight;

并提前获取 context 引用,避免反复 getContext 调用。


🧯 错误处理与用户反馈

当用户拒绝摄像头权限时,我们不能什么都不显示,需要有友好的提示界面:

<template>
  <div v-if="!isStreamActive" class="camera-overlay">
    <p v-if="error">{{ error }}</p>
    <button @click="startCamera">重新启动摄像头</button>
  </div>
</template>

如果浏览器不支持某些特性,我会给出 fallback 提示:

if (!navigator.mediaDevices?.getUserMedia) {
  error.value = '当前浏览器不支持摄像头访问';
}

此外,Safari 对 backdrop-filter 支持有限,我用 CSS 特性检测来处理:

if (!CSS.supports('backdrop-filter', 'blur(1px)')) {
  warn('backdrop-filter 在当前浏览器可能无效');
}

🧩 项目结构整理

整个项目拆分合理,所有逻辑清晰隔离,便于维护和复用:

src/
├── assets/                  # 资源文件
├── components/
│   ├── CameraView.vue       # 视频捕捉组件
│   ├── Scene.vue            # 场景与 UI 布局
├── composables/
│   └── useGlassEffect.ts    # 特效参数与处理逻辑
├── views/
│   └── ReflectGlassView.vue # 主页面容器
├── App.vue
└── main.ts

每一个组件的职责都明确,CameraView.vue 管理摄像头,Scene.vue 管理 UI,useGlassEffect 管理图像处理逻辑,互不干扰。


🧠 CSS 视觉实现再探

我特地尝试了更自然的“毛玻璃”样式,使用了多层阴影、混合模式与渐变模拟真实质感:

.glass-surface {
  background: rgba(255, 255, 255, 0.05);
  backdrop-filter: blur(3px);
  box-shadow:
    inset 0 0 30px rgba(255,255,255,0.05),
    0 0 50px rgba(0,0,0,0.2);
}

.glass-surface::before {
  content: '';
  position: absolute;
  top: 0;
  left: 0;
  right: 0;
  bottom: 0;
  background: linear-gradient(
    45deg,
    rgba(255, 255, 255, 0.1),
    transparent
  );
  pointer-events: none;
}

🔬 图像处理流程小结

为更直观展现图像反射处理流程,这里用一张图总结:

在这里插入图片描述

这个流程图能帮助理解:虽然用户看起来只是“对着玻璃自拍”,背后却是实时图像采集 + 图像翻转 + 模糊混合多重叠加的协同渲染。


📡 WebRTC 视频采集机制解析

Reflect Glass 能够实时抓取用户的影像,最核心的技术就是 WebRTC 提供的 getUserMedia 接口。

虽然它只有几行代码,但背后牵扯的流程其实蛮复杂的,我也遇到了一些初看容易忽略的点。

🌐 视频采集基本流程

我们一般是这样使用的:

navigator.mediaDevices.getUserMedia({
  video: {
    width: { ideal: 1280 },
    height: { ideal: 720 },
    facingMode: 'user'
  }
})

调用这个方法后,浏览器会做几件事:

  1. 弹出摄像头授权提示
  2. 找到合适的摄像头设备
  3. 返回一个包含视频轨道的 MediaStream 对象
  4. 这个对象被赋值给 <video> 元素的 srcObject

📦 MediaStream 工作流程

这个过程我们可以用图来梳理清楚:

在这里插入图片描述

这个过程非常依赖浏览器支持和用户选择,失败的可能性很高。所以我在代码中加入了多个 fallback。


🔬 图像反射与滤镜合成机制

项目的“亮点”就在于图像反射的真实感。别看只是加个模糊,其实涉及多种图像叠加方式,我从最初的模糊透明,到后来的叠加色彩、亮度提升、甚至尝试了光晕效果。

我使用的 Canvas 技术中,有一个比较关键的属性:globalCompositeOperation,它控制不同图像之间的合成方式。

ctx.globalCompositeOperation = 'lighter'; // 提亮重叠部分

这在一些特定场景下,比如白色衣服对着玻璃反射,会比直接画图更自然。


📐 UI 风格设计心得

Reflect Glass 虽然功能简单,但我花了不少心思在 UI 上。因为摄像头类应用,很容易做得像监控软件或者打卡系统,界面一不小心就显得死板。

我的做法是让它看起来像一面真实的玻璃窗,所以用了玻璃拟态的视觉语言。

🔲 色彩与边框设计

  • 背景色:采用低饱和中性色,强调内容而非框架
  • 边框样式:使用内外阴影、半透明效果模拟玻璃厚度
  • 字体选择:用了系统默认 sans-serif 字体,确保跨平台一致性

🔘 控件风格

  • 按钮是圆形、悬浮样式,便于用户点击
  • 加入动效,例如 hover 状态变化、拍照按钮缩放反馈

🎨 动效与动画细节

虽然没有使用大规模动画库,我还是加入了几种关键动画增强整体交互感。

拍照按钮动效

使用 CSS transform: scale() 实现按钮点击时的缩放感:

.capture-button:active {
  transform: scale(0.95);
}

摄像头启动过渡

用户点击启动摄像头后,我加了一个淡入的动画:

.camera-overlay {
  animation: fadeOut 0.5s ease forwards;
}

@keyframes fadeOut {
  from { opacity: 1; }
  to { opacity: 0; visibility: hidden; }
}

不至于让用户感觉突兀。


🧰 技术细节小贴士

项目中还有很多小技术点,我在这里一并记录:

  • requestAnimationFrame:比 setInterval 更适合视频类 UI 更新,省电不撕裂
  • backdrop-filter 会吃性能,低端设备上建议不要太多层叠
  • canvas.toDataURL() 性能不错,但要注意调用前保证画面稳定,别还在更新
  • ✅ 使用 transform: scale(-1,1) 翻转图像时注意原点问题,要移动坐标或画布宽度偏移

📦 代码拆解讲解一览

为了方便你理解 Reflect Glass 每部分如何运作,我对代码组织和结构做一张总览图:

在这里插入图片描述

这张图帮助我理清了组件依赖关系,也让我在重构的时候更有方向。


✍️ 项目创作过程回顾

这个项目虽然功能单一,但整个开发过程对我来说意义很大。

一开始只是一个灵光一闪的点子,想把玻璃反射做得好看点,没想到会越做越深入。中途几次我都差点打退堂鼓,比如摄像头权限问题太多、某些浏览器 Canvas 滤镜不兼容等,但我都一一攻克。

特别是调试的时候,拿着 iPad 对着自己反复测试反射角度,感觉就像一位理发师对着镜子端详头型一样(笑)。


📚 技术学习收获

我通过这个项目实战了很多平时只停留在文档层的 API,尤其是:

  • 💡 Canvas 图像处理
  • 📸 WebRTC 视频流控制
  • 💅 CSS 的高级特效组合
  • 🧠 Vue3 的组合式 API

更重要的是,我知道了“交互性”不仅仅是点按钮、动页面,而是真正让用户“感觉自己在画面里”。这是我以前做工具类前端项目体会不到的部分。


🏁 总结

Reflect Glass 最终实现了我一开始的愿景:

一个可以模拟玻璃反光、让用户沉浸其中的微型镜像空间。

虽然功能不多,但在技术、体验和细节上我都尽可能打磨,希望它不仅是一面镜子,更是一次视觉和感官的小型旅程。

到现在,每次打开这个项目,我都还会下意识看一下自己的倒影,调整一下姿势,好像真有人在背后看我一样。这种沉浸感,是代码带来的,也是用户感知的,我很喜欢这种奇妙的交汇。