紧急更新 0815
建立了一个 script 可以一键生成 Shader 的一套文件,之后直接编写就好:
npm run create
前言
历经几个月,我们已经学习了不少的Shader
的知识要点,从现在开始我们来整点实际上的项目。
在以前的文章里,我们的编写都局限在某一个glsl
或者html
文件里的,这样可以方便我们学习某个特定的知识点。
但是,真正的项目肯定是不可以这么写,我们需要组织我们的项目。
我挑选的是Vite + React
的组合方式。这个组合对于我现在的技能熟练度是最高的。
项目的启动模板贴在这里:
git clone -b base git@github.com:ArisaTaki/Shader-Template.git
我们直接从 Shader 部分开始,项目的启动就是前端的那一套,进入到目录内,然后
npm i
npm run dev
就可以了。
项目搭建
在src
目录下面新建Experience
目录,并在该目录下面新建两个文件,一个叫做Experience.ts
一个叫做ObjectEnum.ts
他们的作用分别是:
- Experience:导出一个
Experience
类,继承了kokomi.js
的Base
类,找到传入的id
,并且挂载上去 - ObjectEnum:枚举,写了几个不同的
Object
,就加入几个项,用于区分给接下来的World
类渲染什么内容设计的。
import * as kokomi from "kokomi.js";
import World from "./World";
import * as THREE from "three";
import ObjectEnum from "./ObjectEnum";
import Debug from "./Debug";
import { resources } from "./Resources";
export interface ExperienceConfig {
id: string;
// 传递给 World 的配置
objectEnum: ObjectEnum;
// 先设定一个设置摄像机位置的参数
cameraPosition?: THREE.Vector3;
}
export default class Experience extends kokomi.Base {
world: World;
debug: Debug;
am: kokomi.AssetManager;
constructor(config: ExperienceConfig) {
super(config.id);
// 动态设置摄像机位置
const cameraPosition = config?.cameraPosition || new THREE.Vector3(0, 0, 5);
// 把摄像机的位置设置给camera
this.camera.position.copy(cameraPosition);
// 添加轨道控制
new kokomi.OrbitControls(this);
// 挂载Experience类到全局
window.experience = this;
// 挂载Debug类
this.debug = new Debug();
// 挂载资源管理类
this.am = new kokomi.AssetManager(this, resources);
// 使用配置初始化 World
this.world = new World(this, config?.objectEnum);
}
}
const enum ObjectEnum {
// 测试Object
TestObject = 0,
// 基础Object
BaseObject = 1,
}
export default ObjectEnum;
World
World
负责创建场景内的所有物体。
在Experience
目录下面创建World
目录,并且在里面创建index.ts
文件,这就是对外导出的内容了,其中也包括了对内的其他World
的集成。
import * as kokomi from "kokomi.js";
import ObjectEnum from "../ObjectEnum";
import Experience from "../Experience";
import LoadingStyles from "@/components/loadingComp/styles.module.css";
import BaseWorld from "./BaseWorld";
import TestWorld from "./TestWorld";
export default class World extends kokomi.Component {
// 将base定义为Experience类
declare base: Experience;
constructor(base: Experience, objectEnum?: ObjectEnum) {
super(base);
// 加载好材质之后进行的行为
this.base.am.on("ready", () => {
setTimeout(() => {
document
.querySelector(`.${LoadingStyles["loader-screen"]}`)
?.classList.add(LoadingStyles["hollow"]);
// 根据objectEnum决定渲染哪一个shader Object
switch (objectEnum) {
case ObjectEnum.TestObject:
new TestWorld(this.base);
break;
case ObjectEnum.BaseObject:
new BaseWorld(this.base);
break;
}
}, 2000);
});
}
}
World
目录大概是这样的:
Objects
这是用于装填新建Object
的地方,这里就是我们自定义的内容了。
这里面的配置就是专门的Object
,其中有本体的ts
文件,之后就是Shaders
文件夹,用于存放顶点着色器和片元着色器。
我们拿TestObject
举例:
import * as THREE from "three";
import * as kokomi from "kokomi.js";
import testObjectVertexShader from "./Shaders/vert.glsl";
import testObjectFragmentShader from "./Shaders/frag.glsl";
import Experience from "@/Experience/Experience";
export default class TestObject extends kokomi.Component {
declare base: Experience;
material: THREE.ShaderMaterial;
mesh: THREE.Mesh<
THREE.SphereGeometry,
THREE.ShaderMaterial,
THREE.Object3DEventMap
>;
uj: kokomi.UniformInjector;
constructor(base: Experience) {
super(base);
const params = {
uDistort: {
value: 1,
},
};
const geometry = new THREE.SphereGeometry(2, 64, 64);
const material = new THREE.ShaderMaterial({
vertexShader: testObjectVertexShader,
fragmentShader: testObjectFragmentShader,
});
this.material = material;
const mesh = new THREE.Mesh(geometry, material);
this.mesh = mesh;
const uj = new kokomi.UniformInjector(this.base);
this.uj = uj;
material.uniforms = {
...material.uniforms,
...uj.shadertoyUniforms,
...params,
};
const debug = this.base.debug;
if (debug.active) {
const debugFolder = debug.ui?.addFolder("testObject");
debugFolder
?.add(params.uDistort, "value")
.min(0)
.max(2)
.step(0.01)
.name("distort");
}
}
addExisting() {
this.container.add(this.mesh);
}
update() {
this.uj.injectShadertoyUniforms(this.material.uniforms);
}
}
其实也没有什么好说的,因为基本上都是很熟悉的内容,前面的文章一步步带大家走过来的,shader 的编程代码。
在这里,因为vite
不能直接读取.glsl
文件,所以我们要借助一个插件vite-plugin-glsl
。
npm i -D vite-plugin-glsl
之后在vite.config.ts
里面声明引入:
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import path from "path";
import glsl from "vite-plugin-glsl";
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react(), glsl()],
server: {
open: true,
},
resolve: {
alias: {
"@": path.resolve(__dirname, "src"),
},
},
});
我们在Shaders
文件夹里加入.glsl
文件,内容比如说是这样的:
#include "/node_modules/lygia/generative/cnoise.glsl"
uniform float iTime;
uniform vec3 iResolution;
uniform vec4 iMouse;
varying vec2 vertexUv;
uniform float uDistort;
vec3 distort(vec3 p){
float noise=cnoise(p+iTime);
p+=noise*normal*.3*uDistort;
return p;
}
void main(){
vec3 p=position;
p=distort(p);
gl_Position=projectionMatrix*modelViewMatrix*vec4(p,1.);
vertexUv=uv;
}
然后我们导入它的办法就是
import testObjectVertexShader from "./Shaders/vert.glsl";
...
const material = new THREE.ShaderMaterial({
vertexShader: testObjectVertexShader,
fragmentShader: testObjectFragmentShader,
});
lygia
lygia
是一个有着很多实用函数的Shader
库,可以说是Shader
界的 lodash
。
前面在写glsl
的时候你可能注意到了这一行代码:
#include "/node_modules/lygia/generative/cnoise.glsl"
这是怎么来的?
npm i lygia
这样来的。
插件vite-plugin-glsl
使我们能够用#include
语法来引入Shader
模块,这行代码的意思就是顶点着色器的顶部引入一个柏林噪声函数cnoise
。
调试
之前我们在写单文件Shader
的时候,总是会对一些值进行各种微调,而且这些微调是纯粹靠手输,稍微有点繁琐,那能不能做成拖拽式的GUI
呢?
在拿TestObject
举例的时候,你肯定也注意到了,有一个关键词debug
,它就是我们的调试工具了。
你需要安装一个调试专用的库——lil-gui
。
npm i lil-gui
在Experience
目录下,新建Debug
目录。在其中建立新的文件index.ts
import * as dat from "lil-gui";
export default class Debug {
active: boolean;
ui?: dat.GUI;
constructor() {
// 通过哈希值来判断是否激活调试工具的GUI
this.active = window.location.hash === "#debug";
if (this.active) {
this.ui = new dat.GUI();
}
}
}
为什么会有个别名dat
?
那是因为three.js
在早期使用的调试库名叫dat.gui
,而它的引入方式就是import * as dat from "dat.gui"
;,现在的调试库则换成了 lil-gui
,为了保留这一传统就这么写了。
要调试的值通常都会存在一个对象里,我们在初始化material
前创建一个param
s 的对象来存放调试的值,创建一个uDistort
的值,表明扭曲的程度,把调试值合并至material.uniforms
里。可以拉回上面仔细查看代码。
给debugFolder
添加uDistort
的值,并且设置好值的范围(这里是[0,2]),步长设为0.01
,属性名设为distort
。
if (debug.active) {
const debugFolder = debug.ui?.addFolder("testObject");
debugFolder
?.add(params.uDistort, "value")
.min(0)
.max(2)
.step(0.01)
.name("distort");
}
Shader
里还需要声明这个 uniform
变量,在顶点着色器的上方声明 uDistort
变量。
uniform float uDistort;
要把这个变量给用上去,在distort
函数中,给噪声值noise
乘上uDistort
。
vec3 distort(vec3 p){
float noise=cnoise(p+iTime);
p+=noise*normal*.3*uDistort;
return p;
}
资源管理
在kokomi.js
中,有一个AssetManager
的类,可以用于统一的资源管理。
我们还是和以前一样,创建一个Resources
目录。并且在下面创建对应的ts
文件。
静态资源基本都是放在 public 里面的,我们在下面创建一个textures
文件夹用来存放纹理图片,这些图片在之前的文章里有提到如何获取,可以参考光照模型相关的文章。
import * as kokomi from "kokomi.js";
export const resources: kokomi.ResourceItem[] = [
{
name: "skyBox",
type: "texture",
path: "textures/skyBox.png",
},
];
这里贴一张可供下载的天空盒图片:下载地址
按照之前已经贴出来的代码,在资源加载之后,就能得到完整的贴图了。
出于前端工作的习惯,给资源加载之前填上了一些加载动画的效果,不作过多解释,这不是这次学习的重点。
我们来看一下这个模板之下的第一个web shader
的效果吧~
因为搭建模板的细节过多,本文章的重点其实是是贴出来这个项目的仓库地址。。。所以更多的搭建过程都被细化了,希望大家能够直接访问项目仓库代码进行查阅:
项目地址:我是地址