WebGL旅游网站案例研究
Plongez dans Lyon网站终于上线了。 我们与 Danka 团队和 Nico Icecream 共同努力,打造了一个令我们特别自豪的流畅的沉浸式网站。
这个网站是专为 ONLYON Tourism 和会议而建,旨在展示里昂最具标志性的活动场所。观看简短的介绍视频后,用户可以进入城市的交互式风景如画的地图,所有场馆都建模为 3D 对象。 每个建筑物都可以点击,进入一个详细说明位置信息的专用页面。
1、打造沉浸式体验
主要网站导航体验依赖于卡通般的 WebGL 场景,其中包含大量景观元素、云彩、动画车辆、波光粼粼的河流,当然还有建筑物。
总而言之,它由 63 个几何图形、48 个纹理、32234 个三角形(以及一些后期处理魔法)组成。 当你处理大量对象时,必须组织代码架构并使用一些技巧来优化性能。
2、3D场景
所有模型均由才华横溢的 3D 艺术家 Nicolas Dufoure(又名 Icecream)在 3ds Max 中创建,然后使用 Blender 导出为 GTLF 对象。如果你有一些现成的3D模型可以利用,那么可以使用这个在线3D格式转换工具将它们转换成GLTF模型,这会节省不少时间。
2.1 艺术指导和视觉构成
Nico 和 Danka 团队从地图的早期迭代开始了项目的创作过程,并很快确定了低多边形和丰富多彩的艺术方向。
我们知道必须添加两打可点击的建筑物,因此我们必须在视觉构图、导航便利性和性能之间找到适当的平衡。
为了将绘制的三角形数量保持在最低限度,我们还很快决定限制场景左侧和右侧远侧的 3D 对象的数量。 但过了一段时间,我们意识到我们实际上必须阻止用户看到这些区域。
2.2 相机操作
为了避免平移、缩放和动画之间的任何冲突,我很早就决定从头开始编写相机控件的代码。 事实证明这非常方便,因为之后为相机可能的位置添加阈值并不困难。
这样,我们成功地限制了相机的移动,同时仍然允许用户探索所有地图重要区域。
2.3 烘焙和压缩纹理
为了节省大量 GPU 工作负载,Nico 和我同意的另一件事是用全局照明和阴影烘焙所有纹理。
当然,这意味着更多的建模工作,如果你的场景需要频繁更改,这可能会很烦人。 但它减轻了 GPU 的大量计算负担(光照阴影、阴影贴图……),在我们的例子中,这绝对是值得的。
当处理如此数量的纹理(通常为 1024x1024、2048x2048 甚至 4096x4096 像素宽)时,你应该考虑的另一件事是使用基础压缩纹理。
如果你从未听说过,基础纹理基本上比 jpeg/png 纹理占用更少的 GPU 内存。 当它们从 CPU 上传到 GPU 时,它们还可以降低主线程瓶颈。
你可以在这里非常轻松地生成基础纹理。
3、代码架构和组织
当需要处理如此多的资源时,组织代码的最佳方法是创建几个 javascript 类(或函数,当然取决于你)并将它们组织在目录和文件中。
通常,我是这样组织该项目的文件和文件夹的:
webgl
|-- data
| |-- objects.js
| |-- otherObjects.js
|-- shaders
| |-- customShader.js
| |-- anotherShader.js
|-- CameraController.js
|-- GroupRaycaster.js
|-- ObjectsLoader.js
|-- WebGLExperience.js
- 数据文件夹包含单独文件中的 javascript 对象以及所有信息
- 着色器文件夹包含单独文件中的所有项目自定义着色器
- CameraController.js:处理所有相机移动和控制的类
- GroupRaycaster.js:处理所有“交互式”对象光线投射的类
- ObjectsLoader.js:加载所有场景对象的类
- WebGLExperience.js:初始化渲染器、相机、场景、后处理并处理所有其他类的主类
当然,你可以自由地以不同的方式组织它。 例如,有些人喜欢为渲染器、场景和相机创建单独的类。
3.1 核心的概念代码摘录
那么让我们进入代码本身吧!
以下是一些文件实际外观的详细示例。
Obects.js :
import { customFragmentShader } from "../shaders/customShader";
const sceneObjects = [
{
subPath: "path/to/",
gltf: "object1.gltf"
},
{
subPath: "anotherPath/to/",
gltf: "object2.gltf",
fragmentShader: customFragmentShader,
uniforms: {
uTime: {
value: 0,
}
}
}
];
export default sceneObjects;
ObjectsLoader.js:
import { LoadingManager } from "three";
import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader";
import { BasisTextureLoader } from "three/examples/jsm/loaders/BasisTextureLoader";
export default class ObjectsLoader {
constructor({
renderer, // our threejs renderer
basePath = '/', // common base path for all your assets
onLoading = () => {}, // onLoading callback
onComplete = () => {} // onComplete callback
}) {
this.renderer = renderer;
this.basePath = basePath;
this.loadingManager = new LoadingManager();
this.basisLoader = new BasisTextureLoader(this.loadingManager);
// you can also host those files locally if you want
this.basisLoader.setTranscoderPath("/node_modules/three/examples/js/libs/basis/");
this.basisLoader.detectSupport(this.renderer);
this.loadingManager.addHandler(/\.basis$/i, this.basisLoader);
this.loader = new GLTFLoader(this.loadingManager);
this.loader.setPath(this.basePath);
this.onLoading = onLoading;
this.onComplete = onComplete;
this.objects = [];
this.state = {
objectsLoaded: 0,
totalObjects: 0,
isComplete: false,
};
this.loadingManager.onProgress = (url, itemsLoaded, itemsTotal) => {
const percent = Math.ceil((itemsLoaded / itemsTotal) * 100);
// loading callback
this.onLoading && this.onLoading(percent);
if(percent === 100 && !this.state.isComplete) {
this.state.isComplete = true;
this.isLoadingComplete();
}
};
this.loadingManager.onError = (url) => {
console.warn('>>> error while loading: ', url);
};
}
loadObject({
object,
parent, // could be our main scene or a group
onSuccess = () => {} // callback for each object loaded if needed
}) {
if(!object || !object.gltf) return;
if('requestIdleCallback' in window) {
window.requestIdleCallback(() => {
this.startLoading({
object,
parent,
onSuccess
});
});
}
else {
this.startLoading({
object,
parent,
onSuccess
});
}
}
startLoading({
object,
parent,
onSuccess
}) {
this.state.totalObjects++;
// if object has a subpath
if(object.subPath) {
this.loader.setPath(this.basePath + object.subPath);
}
this.loader.load(object.gltf, (gltf) => {
const sceneObject = {
gltf,
};
// ... do whatever you want with your gltf scene here
// ... like using a ShaderMaterial if object.fragmentShader is defined for example!
parent.add(gltf.scene);
this.objects.push(sceneObject);
onSuccess && onSuccess(sceneObject);
// check if we've load everything
this.state.objectsLoaded++;
this.isLoadingComplete();
}, (xhr) => {
},(error) => {
console.warn( 'An error happened', error );
this.state.objectsLoaded++;
this.isLoadingComplete();
});
}
isLoadingComplete() {
if(this.state.isComplete && this.state.objectsLoaded === this.state.totalObjects) {
setTimeout(() => {
this.onComplete && this.onComplete();
}, 0);
}
}
}
WebGLExperience.js:
import {
WebGLRenderer,
Scene,
sRGBEncoding,
Group
} from "three";
import ObjectsLoader from "./ObjectsLoader";
import CameraController from "./CameraController";
import GroupRaycaster from "./GroupRaycaster";
import sceneObjects from "./data/objects";
/***
Project architecture example:
webgl
|-- data
| |-- objects.js
| |-- otherObjects.js
|-- shaders
| |-- customShader.js
| |-- anotherShader.js
|-- CameraController.js
|-- GroupRaycaster.js
|-- ObjectsLoader.js
|-- WebGLExperience.js
*/
export default class WebGLExperience {
constructor({
// add params here if needed
container = document.body,
}) {
this.container = container;
// update on resize
this.width = window.innerWidth;
this.height = window.innerHeight;
this.initRenderer();
this.initScene();
this.initCamera();
this.loadObjects();
this.initRaycasting();
}
/*** EVENTS CALLBACKS ***/
onLoading(callback) {
if(callback) {
this.onLoadingCallback = callback;
}
return this;
}
onComplete(callback) {
if(callback) {
this.onCompleteCallback = callback;
}
return this;
}
/*** THREEJS SETUP ***/
initRenderer() {
this.renderer = new WebGLRenderer({
antialias: true,
alpha: true,
});
// important when dealing with GLTFs!
this.renderer.outputEncoding = sRGBEncoding;
this.renderer.setSize( this.width, this.height );
this.renderer.setClearColor( 0xffffff, 1 );
this.renderer.outputEncoding = sRGBEncoding;
// append the canvas
this.container.appendChild( this.renderer.domElement );
}
initScene() {
// scene
this.scene = new Scene();
}
initCamera() {
// creates the camera and handles the controls & movements
this.cameraController = new CameraController({
webgl: this,
});
this.camera = this.cameraController.camera;
}
/*** RAYCASTING ***/
initRaycasting() {
this.raycaster = new GroupRaycaster({
camera: this.camera,
width: this.width,
height: this.height,
onMouseEnteredObject: (object) => {
// raycasted object mouse enter event
},
onMouseLeavedObject: (object) => {
// raycasted object mouse leave event
},
onObjectClicked: (object) => {
// raycasted object mouse click event
}
});
}
/*** LOAD OBJECTS ***/
loadObjects() {
this.objectsLoader = new ObjectsLoader({
renderer: this.renderer,
basePath: '/assets/', // whatever
onLoading: (percent) => {
console.log(percent);
// callback
this.onLoadingCallback && this.onLoadingCallback(percent);
},
onComplete: () => {
// loading complete...
console.log("loading complete!");
// callback
this.onCompleteCallback && this.onCompleteCallback();
}
});
// create a new group where we'll add all our objects
this.objectGroup = new Group();
this.scene.add(this.objectGroup);
// load the objects
sceneObjects.forEach(object => {
this.objectsLoader.loadObject({
object,
parent: this.objectGroup,
onSuccess: (loadedObject) => {
console.log(loadedObject);
}
});
});
}
/*** RENDERING ***/
// ...other methods to handle rendering, interactions, etc.
}
3.2 与 Nextjs / React 集成
由于该项目使用 Nextjs,我们需要在 React 组件内实例化我们的 WebGLExperience 类。
我们只需创建一个 WebGLCanvas 组件并将其放在路由器外部,以便它始终位于 DOM 中。
WebGLCanvas.jsx:
import React, {useRef, useState, useEffect} from 'react';
import WebGLExperience from '../../webgl/WebGLExperience';
import styles from './WebGLCanvas.module.scss';
export default function WebGLCanvas() {
const container = useRef();
const [ webglXP, setWebglXP ] = useState();
// set up webgl context on init
useEffect(() => {
const webgl = new WebGLExperience({
container: container.current,
});
setWebglXP(webgl);
}, []);
// now we can watch webglXP inside a useEffect hook
// and do what we want with it
// (watch for events callbacks for example...)
useEffect(() => {
if(webglXP) {
webglXP
.onLoading((percent) => {
console.log('loading', percent);
})
.onComplete(() => {
// do what you want (probably dispatch a context event)
});
}
}, [webglXP]);
return (
<div className="WebGLCanvas" ref={container} />
);
};
4、自定义着色器
显然我必须为这个网站从头开始编写一些自定义着色器。
以下是最有趣的一些细分。
4.1 着色器块
如果你仔细查看上面的示例代码,会发现我允许每个对象在需要时使用自己的自定义着色器。
事实上,场景中的每个网格体都使用 ShaderMaterial,因为当你单击建筑物时,灰度滤镜将应用于所有其他场景网格体:
这种效果的实现要归功于这段超级简单的 glsl 代码:
const grayscaleChunk = `
vec4 textureBW = vec4(1.0);
textureBW.rgb = vec3(gl_FragColor.r * 0.3 + gl_FragColor.g * 0.59 + gl_FragColor.b * 0.11);
gl_FragColor = mix(gl_FragColor, textureBW, uGrayscale);
`;
由于所有对象都必须遵守此行为,因此我将其实现为“着色器块”,就像 Three.js 最初在内部构建自己的着色器的方式一样。
例如,使用的最基本场景的网格片段着色器如下所示:
varying vec2 vUv;
uniform sampler2D map;
uniform float uGrayscale;
void main() {
gl_FragColor = texture2D(map, vUv);
#include <grayscale_fragment>
}
然后我们只获取材质的 onBeforeCompile 方法的一部分:
material.onBeforeCompile = shader => {
shader.fragmentShader = shader.fragmentShader.replace(
"#include <grayscale_fragment>",
grayscaleChunk
);
};
这样,如果我必须调整灰度效果,我只需修改一个文件,它就会更新我的所有片段着色器。
4.2 云
正如我上面提到的,我们决定不在场景中放置任何真实的灯光。 但由于云层正在(缓慢)移动,因此需要对其应用某种动态闪电。
为此,我需要做的第一件事是将顶点世界位置和法线传递给片段着色器:
varying vec3 vNormal;
varying vec3 vWorldPos;
void main() {
vec4 mvPosition = modelViewMatrix * vec4(position, 1.0);
gl_Position = projectionMatrix * mvPosition;
vWorldPos = (modelMatrix * vec4(position, 1.0)).xyz;
vNormal = normal;
}
然后在片段着色器中,我使用它们根据一些uniforms计算漫反射闪电:
varying vec3 vNormal;
varying vec3 vWorldPos;
uniform float uGrayscale;
uniform vec3 uCloudColor; // emissive color
uniform float uRoughness; // material roughness
uniform vec3 uLightColor; // light color
uniform float uAmbientStrength; // ambient light strength
uniform vec3 uLightPos; // light world space position
// get diffusion based on material's roughness
// see https://learnopengl.com/PBR/Theory
float getRoughnessDiff(float diff) {
float diff2 = diff * diff;
float r2 = uRoughness * uRoughness;
float r4 = r2 * r2;
float denom = (diff2 * (r4 - 1.0) + 1.0);
denom = 3.141592 * denom * denom;
return r4 / denom;
}
void main() {
// ambient light
vec3 ambient = uAmbientStrength * uLightColor;
// get light diffusion
float diff = max(dot(normalize((uLightPos - vWorldPos)), vNormal), 0.0);
// apply roughness
float roughnessDiff = getRoughnessDiff(diff);
vec3 diffuse = roughnessDiff * uLightColor;
vec3 result = (ambient + diffuse) * uCloudColor;
gl_FragColor = vec4(result, 1.0);
#include <grayscale_fragment>
}
这是一种从头开始应用基本闪电阴影的廉价方法,而且结果足够令人信服。
4.3 水中倒影
我花更多时间写的片段着色器无疑是波光粼粼的水。
起初,我愿意采用与 Bruno Simon 在 Madbox 网站上所做的类似的方法,但他使用额外的网格和一组自定义 UV 来实现。
由于 Nico 已经忙于所有建模工作,我决定尝试另一种方法。 我为自己创建了一个额外的纹理来计算波的方向:
这里,水流方向被编码在绿色通道中:50% 的绿色表示水流直行,60% 的绿色表示水稍微向左流动,40% 表示水稍微向右流动,等等 在…
为了创建波浪,我使用了带有阈值的 2D perlin 噪声。 我使用了其他一些 2D 噪声来确定水会发光的区域,使它们向相反的方向移动,瞧!
varying vec2 vUv;
uniform sampler2D map;
uniform sampler2D tFlow;
uniform float uGrayscale;
uniform float uTime;
uniform vec2 uFrequency;
uniform vec2 uNaturalFrequency;
uniform vec2 uLightFrequency;
uniform float uSpeed;
uniform float uLightSpeed;
uniform float uThreshold;
uniform float uWaveOpacity;
// see https://gist.github.com/patriciogonzalezvivo/670c22f3966e662d2f83#classic-perlin-noise
// for cnoise function
vec2 rotateVec2ByAngle(float angle, vec2 vec) {
return vec2(
vec.x * cos(angle) - vec.y * sin(angle),
vec.x * sin(angle) + vec.y * cos(angle)
);
}
void main() {
vec4 flow = texture2D(tFlow, vUv);
float sideStrength = flow.g * 2.0 - 1.0;
vec2 wavesUv = rotateVec2ByAngle(sideStrength * PI, vUv) * uFrequency;
float mainFlow = uTime * uSpeed * (1.0 - sideStrength);
float sideFlow = uTime * sideStrength * uSpeed;
wavesUv.x -= sideFlow;
wavesUv.y += mainFlow;
// make light areas travel towards the user
float waveLightStrength = cnoise(wavesUv);
// make small waves with noise
vec2 naturalNoiseUv = rotateVec2ByAngle(sideStrength * PI, vUv * uNaturalFrequency);
float naturalStrength = cnoise(naturalNoiseUv);
// apply a threshold to get small waves moving towards the user
float waveStrength = step(uThreshold, clamp(waveLightStrength - naturalStrength, 0.0, 1.0));
// a light mowing backward to improve overall effect
float light = cnoise(vUv * uLightFrequency + vec2(uTime * uLightSpeed));
// get our final waves colors
vec4 color = vec4(1.0);
color.rgb = mix(vec3(0.0), vec3(1.0), 1.0 - step(waveStrength, 0.01));
// exagerate effect
float increasedShadows = pow(abs(light), 1.75);
color *= uWaveOpacity * increasedShadows;
// mix with original texture
vec4 text = texture2D(map, vUv);
gl_FragColor = text + color;
#include <grayscale_fragment>
}
如果你想测试一下,这里有一个 Shadertoy 上的演示。
为了帮助我调试这个问题,我使用了 GUI 来实时调整所有值并找到最有效的值(当然,我已经使用该 GUI 来帮助我调试很多其他事情) 。
4.4 后期处理
最后有一个使用 Threejs 内置 ShaderPass 类应用的后处理通道。 它处理出现的动画,在某个位置聚焦时在相机移动上添加一点鱼眼,并负责小级别校正(亮度、对比度、饱和度和曝光)。
PostFXShader.js:
const PostFXShader = {
uniforms: {
'tDiffuse': { value: null },
'deformationStrength': { value: 0 },
'showScene': { value: 0 },
// color manipulations
'brightness': { value: 0 },
'contrast': { value: 0.15 },
'saturation': { value: 0.1 },
'exposure': { value: 0 },
},
vertexShader: /* glsl */`
varying vec2 vUv;
void main() {
vUv = uv;
gl_Position = projectionMatrix * modelViewMatrix * vec4( position, 1.0 );
}`,
fragmentShader: `
varying vec2 vUv;
uniform sampler2D tDiffuse;
uniform float showScene;
uniform float deformationStrength;
uniform float brightness;
uniform float contrast;
uniform float saturation;
uniform float exposure;
vec3 adjustBrightness(vec3 color, float value) {
return color + value;
}
vec3 adjustContrast(vec3 color, float value) {
return 0.5 + (1.0 + value) * (color - 0.5);
}
vec3 adjustExposure(vec3 color, float value) {
return color * (1.0 + value);
}
vec3 adjustSaturation(vec3 color, float value) {
// https://www.w3.org/TR/WCAG21/#dfn-relative-luminance
const vec3 luminosityFactor = vec3(0.2126, 0.7152, 0.0722);
vec3 grayscale = vec3(dot(color, luminosityFactor));
return mix(grayscale, color, 1.0 + value);
}
void main() {
vec2 texCoords = vUv;
vec2 normalizedCoords = texCoords * 2.0 - 1.0;
float distanceToCenter = distance(normalizedCoords, vec2(0.0));
vec2 distortedCoords = normalizedCoords * (1.0 - distanceToCenter * deformationStrength);
vec2 offset = normalizedCoords * sin(distanceToCenter * 3.0 - showScene * 3.0) * (1.0 - showScene) * 0.1;
texCoords = (distortedCoords + 1.0) * 0.5 + offset;
vec4 texture = texture2D(tDiffuse, texCoords);
float showEffect = clamp(showScene - length(offset) * 10.0 / sqrt(2.0), 0.0, 1.0);
vec4 grayscale = vec4(1.0);
grayscale.rgb = vec3(texture.r * 0.3 + texture.g * 0.59 + texture.b * 0.11);
texture.rgb = mix(grayscale.rgb, texture.rgb, showEffect);
texture.a = showEffect * 0.9 + 0.1;
texture.rgb *= texture.a;
texture.rgb = adjustBrightness(texture.rgb, brightness);
texture.rgb = adjustContrast(texture.rgb, contrast);
texture.rgb = adjustExposure(texture.rgb, exposure);
texture.rgb = adjustSaturation(texture.rgb, saturation);
gl_FragColor = texture;
}
`
};
export { PostFXShader };
在某些时候,我们还尝试添加散景通道,但它对性能要求太高,因此我们很快就放弃了它。
5、使用 Spector 进行调试
你始终可以通过安装spector.js扩展并检查WebGL上下文来深入查看使用的所有着色器。
如果你从未听说过,spector.js 适用于每个 WebGL 网站。 如果想检查一些 WebGL 效果是如何实现的,它总是超级方便!
6、性能优化
我使用了一些技巧来优化体验性能。 以下是最重要的两个:
首先,这应该成为一种习惯:仅在需要时渲染场景。
这可能听起来很愚蠢,但它仍然经常被低估。 如果你的场景被覆盖层、页面或其他任何东西隐藏,就不要绘制它!
renderScene() {
if(this.state.shouldRender) this.animate();
}
我使用的另一个技巧是根据用户 GPU 和屏幕尺寸来调整场景的像素比。
这个想法是首先使用 detector-gpu 检测用户的 GPU。 一旦我们获得了 GPU 估计的 fps,我们就会使用实际屏幕分辨率来计算实际条件下该 fps 测量值的增强估计。 然后,我们可以根据每次调整大小时的这些数字来调整渲染器像素比:
setGPUTier() {
// GPU test
(async () => {
this.gpuTier = await getGPUTier({
glContext: this.renderer.getContext(),
});
this.setImprovedGPUTier();
})();
}
// called on resize as well
setImprovedGPUTier() {
const baseResolution = 1920 * 1080;
this.gpuTier.improvedTier = {
fps: this.gpuTier.fps * baseResolution / (this.width * this.height)
};
this.gpuTier.improvedTier.tier = this.gpuTier.improvedTier.fps >= 60 ? 3 :
this.gpuTier.improvedTier.fps >= 30 ? 2 :
this.gpuTier.improvedTier.fps >= 15 ? 1 : 0;
this.setScenePixelRatio();
}
另一种常见的方法是持续监控给定时间段内的平均 FPS,并根据结果调整像素比。
其他优化包括使用或不使用多重采样渲染目标,具体取决于 GPU 和 WebGL2 支持(使用 FXAA 通道作为后备)、使用鼠标事件发射器、触摸和调整大小事件、使用 gsap 股票代码作为应用程序的唯一 requestAnimationFrame 循环等 。
7、结束语
总而言之,我们在构建家乡的交互式地图时度过了一段愉快的时光。
正如我们所见,打造像这样的沉浸式 WebGL 体验(需要实时渲染很多内容)并不困难。 但它确实需要一些组织和一个包含多个文件的干净代码库,可以轻松调试、添加或删除功能。
通过该架构,还可以非常轻松地添加或删除场景对象(因为这只是编辑 Javascript 对象的问题),从而在需要时可以方便地进行进一步的站点更新。
原文链接:Plongez dans Lyon - WebGL scene case study
BimAnt翻译整理,转载请标明出处