1. 选中物体
这里的思路很简单:
- 用户点击
canvas
- 将物体设置为单一颜色,比如红色
- 读取点击位置的像素颜色
- 如果是红色就是点击到了物体,否则就不是
1.1. 点击canvas
物体变红
// 顶点着色器
// 代码是根据前面改的,主要看判断
uniform bool clicked;
void main(){
// 绘制立方体顶点
gl_Position = mvpMat * pos;
gl_PointSize = 10.0;
// 计算顶点世界坐标
_vertexPosition = modelMat * pos;
// 如果点击了物体就变红
if(bool(clicked)) {
_color = vec4(1.0, 0.0, 0.0, 1.0);
}else {
_color = color;
}
_originalNormal = originalNormal;
}
const clicked = gl.getUniformLocation(program, 'clicked')
canvas.addEventListener('click', function(e){
const {
height,
left,
right,
top,
bottom,
width
} = this.getBoundingClientRect()
const {
clientX,
clientY
} = e
// 点在canvas内部
if(clientX >= left && clientX <= right && clientY <= bottom && clientY >= top) {
// 不能传递true或者false
// 只能间接传递
gl.uniform1i(clicked, 1)
}else {
gl.uniform1i(clicked, 0)
}
draw()
})
1.2. 判断点击物体
这里要注意坐标,html
中原点在左上角。
document.addEventListener('click', function(e){
const {
height,
left,
right,
top,
bottom,
width
} = canvas.getBoundingClientRect()
const {
clientX,
clientY
} = e
// 点在canvas内部
if(clientX >= left && clientX <= right && clientY <= bottom && clientY >= top) {
// clicked标志置为1
gl.uniform1i(clicked, 1)
draw()
// 将浏览器坐标转化为canvas上的坐标
const canvasX = clientX - left
const canvasY = clientY - top
// 使用一个长度为4的8位无符号整形数组接收像素值
const res = new Uint8Array(4)
// 获取像素值
gl.readPixels(canvasX, canvasY, 1, 1, gl.RGBA, gl.UNSIGNED_BYTE, res)
// 如果r分量不是102,102是背景的颜色
if(res[0] !== 102) {
alert('点击了物体')
}
}
// 复原颜色
gl.uniform1i(clicked, 0)
draw()
})
可以发现没有在正确的位置触发弹窗,看起来好像是坐标相对于正确的位置偏移了,这是为什么呢?
这是因为canvas
的设置问题,我的设置是这样
<canvas
id="webgl"
width="1200"
height="1200"
style="width: 600px; height: 600px"
></canvas>
画布实际宽高是1200
,canvas
元素的宽高是600
,这样做相当于dpr = 2
,会让图像更清晰。
同时也会导致基于元素宽高的计算会出现问题,解决方法很简单,就是把比例考虑进去。
// 计算缩放比
const sacle = canvas.width / width
gl.readPixels(canvasX * sacle, canvasY * sacle, 1, 1, gl.RGBA, gl.UNSIGNED_BYTE, res)
还有其他一些方法出几种方法:
- 使用颜色编码:给每个绘制的图像分配一个唯一的颜色,并在另一个不可见的canvas上绘制它们。当用户点击时,获取点击位置的像素颜色,并与颜色编码表进行匹配,找到对应的图像。
- 使用射线拾取:根据用户点击的屏幕坐标,计算出一个从摄像机位置出发的射线,并与绘制的图像的顶点坐标进行相交测试,找到最近的相交点所属的图像。
- 使用深度缓冲区:在绘制图像时,记录每个像素的深度值,并存储在一个不可见的缓冲区中。当用户点击时,获取点击位置的深度值,并与绘制的图像的深度值进行比较,找到最近的图像。
上面代码使用了readPixels
函数,具体作用就是获取指定宽高矩形区域的像素值。
2. 选中物体表面
思路:
- 使用一个缓冲区
face
存储每个顶点是在哪一个面 - 点击画面时将
face
的值存入对应顶点的像素颜色值的α
分量中 - 读取点击位置的像素值,取出
α
分量的值就是表面位置 - 将点击的表面值设置到顶点变量中,使对应的面的顶点颜色变为激活的颜色
2.1. 第一步,创建face
缓冲区
// 顶点着色器
// 先不用管,后面解释
// ....
attribute float faceIndex;
void main(){
// ....
}
// Create a cube
// v6----- v5
// /| /|
// v1------v0|
// | | | |
// | |v7---|-|v4
// |/ |/
// v2------v3
var vertices = new Float32Array([ // Vertex coordinates
1.0, 1.0, 1.0, -1.0, 1.0, 1.0, -1.0,-1.0, 1.0, 1.0,-1.0, 1.0, // v0-v1-v2-v3 front
1.0, 1.0, 1.0, 1.0,-1.0, 1.0, 1.0,-1.0,-1.0, 1.0, 1.0,-1.0, // v0-v3-v4-v5 right
1.0, 1.0, 1.0, 1.0, 1.0,-1.0, -1.0, 1.0,-1.0, -1.0, 1.0, 1.0, // v0-v5-v6-v1 up
-1.0, 1.0, 1.0, -1.0, 1.0,-1.0, -1.0,-1.0,-1.0, -1.0,-1.0, 1.0, // v1-v6-v7-v2 left
-1.0,-1.0,-1.0, 1.0,-1.0,-1.0, 1.0,-1.0, 1.0, -1.0,-1.0, 1.0, // v7-v4-v3-v2 down
1.0,-1.0,-1.0, -1.0,-1.0,-1.0, -1.0, 1.0,-1.0, 1.0, 1.0,-1.0 // v4-v7-v6-v5 back
]);
// 一个顶点坐标对应一个face
var faces = new Uint8Array([ // Faces
1, 1, 1, 1, // v0-v1-v2-v3 front
2, 2, 2, 2, // v0-v3-v4-v5 right
3, 3, 3, 3, // v0-v5-v6-v1 up
4, 4, 4, 4, // v1-v6-v7-v2 left
5, 5, 5, 5, // v7-v4-v3-v2 down
6, 6, 6, 6, // v4-v7-v6-v5 back
]);
// 绑定缓冲区
const faceIndex = gl.getAttribLocation(program, 'faceIndex')
var faceBuffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, faceBuffer)
gl.bufferData(gl.ARRAY_BUFFER, faces, gl.STATIC_DRAW)
gl.vertexAttribPointer(faceIndex, 1, gl.FLOAT, false, 0, 0)
gl.enableVertexAttribArray(faceIndex)
2.2. 第二步,存储face
值
再次回顾一下步骤:
- 点击
canvas
后将face
值写入α
通道 - 读取点击位置的像素值
- 像素值的第四位就是点击的哪一个面
- 将读取出来的
face
值设置给顶点变量 - 将
face
对应的顶点设置成高亮色
所以我们需要设置一个顶点变量clickedFace
,来存储点击的面。
// 顶点着色器
// ....
attribute float faceIndex;
uniform int clickedFace;
void main(){
// ...
// clickedFace == 0代表初始化
// 此时将faceIndex中的值写入对应顶点的颜色α分量
if(clickedFace == 0) {
_color = vec4(color.rgb, faceIndex / 255.0);
}else {
// 这里暂不进行下一步
// 保持原样
_color = color;
}
}
// 主程序
// ....
const clickedFace = gl.getUniformLocation(program, 'clickedFace')
// 初始化clickedFace为-1,绘制的图像保持原样
gl.uniform1i(clickedFace, -1)
draw();
document.addEventListener('click', function(e){
const {
height,
left,
right,
top,
bottom,
width
} = canvas.getBoundingClientRect()
const {
clientX,
clientY
} = e
// 点在canvas内部
if(clientX >= left && clientX <= right && clientY <= bottom && clientY >= top) {
// 设置clickedFace=0,将face值存入α分量
gl.uniform1i(clickedFace, 0)
draw()
const canvasX = clientX - left
const canvasY = clientY - top
const sacle = canvas.width / width
const pixel = new Uint8Array(4)
// 读取点击点的像素值
gl.readPixels(canvasX * sacle, canvasY * sacle, 1, 1, gl.RGBA, gl.UNSIGNED_BYTE, pixel)
}
})
这里注意几点:
- 为什么一定要
clickedFace = 0
时初始化,而不是顶点着色器main
函数一执行就初始化?
如果一开始就初始化,会导致原本颜色的α
分量值不对。
- 顶点着色器中
_color = vec4(color.rgb, faceIndex / 255.0)
,为什么faceIndex / 255
?
- 颜色值范围被归一化为
(0, 1)
,对应无符号字节类型就是(0, 255)
- 所以
readPixels
的输出相当于乘以了255
- 为了能正确显示
2.3. 对比点击处的α
值和faceIndex
// 顶点着色器
// ....
if(clickedFace == 0) {
_color = vec4(color.rgb, faceIndex / 255.0);
}else {
if(clickedFace == int(faceIndex)) {
// 如果点击处的α值等于这个顶点(面)对应的faceIndex
// 变成紫红色
_color = vec4(1, 0.5, 1, color.a);
}else {
_color = color;
}
}
2.4.完整代码
这里的代码是接着前面光照 的代码写的,可以忽略光照的代码
// 顶点着色器
// mvp矩阵
uniform mat4 mvpMat;
// model矩阵
uniform mat4 modelMat;
// 顶点位置
attribute vec4 pos;
// 顶点颜色
attribute vec4 color;
// 初始位置法线向量
attribute vec4 originalNormal;
varying vec4 _color;
varying vec4 _originalNormal;
varying vec4 _vertexPosition;
attribute float faceIndex;
uniform int clickedFace;
void main(){
// 绘制立方体顶点
gl_Position = mvpMat * pos;
gl_PointSize = 10.0;
// 计算顶点世界坐标
_vertexPosition = modelMat * pos;
if(clickedFace == 0) {
_color = vec4(color.rgb, faceIndex / 255.0);
}else {
if(clickedFace == int(faceIndex)) {
_color = vec4(1, 0.5, 1, color.a);
}else {
_color = color;
}
}
// 法向量是存储在顶点缓冲区的,所以只能间接传递给片元着色器
_originalNormal = originalNormal;
}
// 片元着色器 没有改动
precision mediump float;
// 光源颜色
uniform vec3 lightColor;
// 光源方向
uniform vec3 lightDirection;
// 环境光颜色
uniform vec3 ambientColor;
// 法向量变换矩阵 也就是逆转置矩阵
uniform mat4 normalMat;
varying vec4 _color;
varying vec4 _originalNormal;
varying vec4 _vertexPosition;
void main(){
// 计算该顶点的光线方向
vec3 vertexLightDirection = normalize(lightDirection - vec3(_vertexPosition));
// 计算变换后的法向量
vec3 normal = normalize(vec3(normalMat * _originalNormal));
// 计算反射颜色
float cos = max(dot(vertexLightDirection, normal), 0.0);
vec3 diffuse = lightColor * _color.rgb * cos;
// 计算环境反射光
vec3 ambient = ambientColor * _color.rgb;
gl_FragColor = vec4(diffuse + ambient, _color.a);
}
import vertexCode from './vertex.vert'
import fragmentCode from './fragment.frag'
import { mat4 } from 'gl-matrix'
import { getModelMat } from './utils'
const canvas = document.getElementById('webgl') as HTMLCanvasElement
const output = document.getElementById('output') as HTMLDivElement
// 获取webgl上下文
const gl = canvas.getContext('webgl')
// 1.创建顶点、片元着色器
const vertexShader = gl.createShader(gl.VERTEX_SHADER)
const fragmentShader = gl.createShader(gl.FRAGMENT_SHADER)
// 2.装载着色器代码
gl.shaderSource(vertexShader, vertexCode)
gl.shaderSource(fragmentShader, fragmentCode)
// 3.编译着色器
gl.compileShader(vertexShader);
gl.compileShader(fragmentShader);
// 4.创建WebGLProgram对象
const program = gl.createProgram();
// 5.添加着色器
gl.attachShader(program, vertexShader)
gl.attachShader(program, fragmentShader)
// 6.链接程序
gl.linkProgram(program);
// 7.WebGLProgram对象添加到渲染状态中
gl.useProgram(program);
gl.clearColor(0.4, 0.4, 0.4, 1)
gl.clear(gl.COLOR_BUFFER_BIT)
gl.drawArrays(gl.TRIANGLES, 0, 9)
const pos = gl.getAttribLocation(program, 'pos')
const mvpMatrix = gl.getUniformLocation(program, 'mvpMat')
const color = gl.getAttribLocation(program, 'color')
const originalNormal = gl.getAttribLocation(program, 'originalNormal')
var vertexBuffer = gl.createBuffer();
var colorBuffer = gl.createBuffer();
var indexBuffer = gl.createBuffer();
var normalBuffer = gl.createBuffer();
// Create a cube
// v6----- v5
// /| /|
// v1------v0|
// | | | |
// | |v7---|-|v4
// |/ |/
// v2------v3
// 每个面都单独定义了一组顶点
const vertex = new Float32Array([
1.0, 1.0, 1.0, -1.0, 1.0, 1.0, -1.0,-1.0, 1.0, 1.0,-1.0, 1.0, // v0-v1-v2-v3 front
1.0, 1.0, 1.0, 1.0,-1.0, 1.0, 1.0,-1.0,-1.0, 1.0, 1.0,-1.0, // v0-v3-v4-v5 right
1.0, 1.0, 1.0, 1.0, 1.0,-1.0, -1.0, 1.0,-1.0, -1.0, 1.0, 1.0, // v0-v5-v6-v1 up
-1.0, 1.0, 1.0, -1.0, 1.0,-1.0, -1.0,-1.0,-1.0, -1.0,-1.0, 1.0, // v1-v6-v7-v2 left
-1.0,-1.0,-1.0, 1.0,-1.0,-1.0, 1.0,-1.0, 1.0, -1.0,-1.0, 1.0, // v7-v4-v3-v2 down
1.0,-1.0,-1.0, -1.0,-1.0,-1.0, -1.0, 1.0,-1.0, 1.0, 1.0,-1.0 // v4-v7-v6-v5 back
])
// 分离颜色参数
var colors = new Float32Array([
0.4, 0.4, 1.0, 0.4, 0.4, 1.0, 0.4, 0.4, 1.0, 0.4, 0.4, 1.0, // v0-v1-v2-v3 前(blue)
0.4, 1.0, 0.4, 0.4, 1.0, 0.4, 0.4, 1.0, 0.4, 0.4, 1.0, 0.4, // v0-v3-v4-v5 右(green)
1.0, 0.4, 0.4, 1.0, 0.4, 0.4, 1.0, 0.4, 0.4, 1.0, 0.4, 0.4, // v0-v5-v6-v1 上(red)
1.0, 1.0, 0.4, 1.0, 1.0, 0.4, 1.0, 1.0, 0.4, 1.0, 1.0, 0.4, // v1-v6-v7-v2 左
1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, // v7-v4-v3-v2 下
0.4, 1.0, 1.0, 0.4, 1.0, 1.0, 0.4, 1.0, 1.0, 0.4, 1.0, 1.0 // v4-v7-v6-v5 后
]);
// 法线向量
var normals = new Float32Array([
0.0, 0.0, 1.0, 0.0, 0.0, 1.0, 0.0, 0.0, 1.0, 0.0, 0.0, 1.0, // v0-v1-v2-v3 front
1.0, 0.0, 0.0, 1.0, 0.0, 0.0, 1.0, 0.0, 0.0, 1.0, 0.0, 0.0, // v0-v3-v4-v5 right
0.0, 1.0, 0.0, 0.0, 1.0, 0.0, 0.0, 1.0, 0.0, 0.0, 1.0, 0.0, // v0-v5-v6-v1 up
-1.0, 0.0, 0.0, -1.0, 0.0, 0.0, -1.0, 0.0, 0.0, -1.0, 0.0, 0.0, // v1-v6-v7-v2 left
0.0,-1.0, 0.0, 0.0,-1.0, 0.0, 0.0,-1.0, 0.0, 0.0,-1.0, 0.0, // v7-v4-v3-v2 down
0.0, 0.0,-1.0, 0.0, 0.0,-1.0, 0.0, 0.0,-1.0, 0.0, 0.0,-1.0 // v4-v7-v6-v5 back
]);
var faces = new Float32Array([ // Faces
1, 1, 1, 1, // v0-v1-v2-v3 front
2, 2, 2, 2, // v0-v3-v4-v5 right
3, 3, 3, 3, // v0-v5-v6-v1 up
4, 4, 4, 4, // v1-v6-v7-v2 left
5, 5, 5, 5, // v7-v4-v3-v2 down
6, 6, 6, 6, // v4-v7-v6-v5 back
]);
var indices = new Uint8Array([
0, 1, 2, 0, 2, 3, // 前
4, 5, 6, 4, 6, 7, // 右
8, 9, 10, 8, 10, 11, // 上
12, 13, 14, 12, 14, 15, // 左
16, 17, 18, 16, 18, 19, // 下
20, 21, 22, 20, 22, 23 // 后
]);
// 绑定顶点缓冲
gl.bindBuffer(gl.ARRAY_BUFFER, vertexBuffer)
gl.bufferData(gl.ARRAY_BUFFER, vertex, gl.STATIC_DRAW)
gl.vertexAttribPointer(pos, 3, gl.FLOAT, false, 0, 0)
gl.enableVertexAttribArray(pos)
// 绑定颜色缓冲
gl.bindBuffer(gl.ARRAY_BUFFER, colorBuffer)
gl.bufferData(gl.ARRAY_BUFFER, colors, gl.STATIC_DRAW)
gl.vertexAttribPointer(color, 3, gl.FLOAT, false, 0, 0)
gl.enableVertexAttribArray(color)
// 绑定法线向量缓冲
gl.bindBuffer(gl.ARRAY_BUFFER, normalBuffer)
gl.bufferData(gl.ARRAY_BUFFER, normals, gl.STATIC_DRAW)
gl.vertexAttribPointer(originalNormal, 3, gl.FLOAT, false, 0, 0)
gl.enableVertexAttribArray(originalNormal)
// 绑定面
const faceIndex = gl.getAttribLocation(program, 'faceIndex')
var faceBuffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, faceBuffer)
gl.bufferData(gl.ARRAY_BUFFER, faces, gl.STATIC_DRAW)
gl.vertexAttribPointer(faceIndex, 1, gl.FLOAT, false, 0, 0)
gl.enableVertexAttribArray(faceIndex)
// 绑定索引
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, indexBuffer);
gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, indices, gl.STATIC_DRAW);
// 设置光线
const lightColor = gl.getUniformLocation(program, 'lightColor')
const lightDirection = gl.getUniformLocation(program, 'lightDirection')
gl.uniform3fv(lightColor, [1, 1, 1])
// 光线方向
let lightPos = [ -4, 4, 1]
gl.uniform3fv(lightDirection, lightPos)
// 设置环境光颜色
const ambientColor = gl.getUniformLocation(program, 'ambientColor')
gl.uniform3fv(ambientColor, [0.2, 0.2, 0.2])
// 绘制立方体
let fov = 10 * Math.PI / 180
let aspect = canvas.width / canvas.height
let near = 1
let far = 100
gl.enable(gl.DEPTH_TEST)
const normalMatrix = gl.getUniformLocation(program, 'normalMat')
const modelMatrix = gl.getUniformLocation(program, 'modelMat')
let modelMat: mat4 = getModelMat([0, 0, 0], [30, 30, 0])
const drawBox = (modelMat: mat4) => {
const perspectiveMat = mat4.perspective(mat4.create(), fov, aspect, near, far)
const _viewMat = mat4.lookAt(mat4.create(), [0, 0, 30], [0, 0, 0], [0, 1, 0])
let mvpMat = mat4.multiply(mat4.create(), perspectiveMat, _viewMat)
mvpMat = mat4.multiply(mat4.create(), mvpMat, modelMat)
// modelMat的逆转置
gl.uniformMatrix4fv(normalMatrix, false, mat4.transpose(mat4.create(), mat4.invert(mat4.create(), modelMat)))
gl.uniformMatrix4fv(modelMatrix, false, modelMat)
gl.uniformMatrix4fv(mvpMatrix, false, mvpMat)
gl.drawElements(gl.TRIANGLES, indices.length, gl.UNSIGNED_BYTE, 0);
}
const draw = () => {
gl.clear(gl.DEPTH_BUFFER_BIT | gl.COLOR_BUFFER_BIT)
gl.clearColor(0.4, 0.4, 0.4, 1)
drawBox(modelMat)
}
const clickedFace = gl.getUniformLocation(program, 'clickedFace')
gl.uniform1i(clickedFace, -1)
draw();
document.addEventListener('click', function(e){
const {
height,
left,
right,
top,
bottom,
width
} = canvas.getBoundingClientRect()
const {
clientX,
clientY
} = e
// 点在canvas内部
if(clientX >= left && clientX <= right && clientY <= bottom && clientY >= top) {
// 初始化,用α通道存储faceIndex
gl.uniform1i(clickedFace, 0)
draw()
const canvasX = clientX - left
const canvasY = clientY - top
const sacle = canvas.width / width
const pixel = new Uint8Array(4)
gl.readPixels(canvasX * sacle, canvasY * sacle, 1, 1, gl.RGBA, gl.UNSIGNED_BYTE, pixel)
// 设置被点击的面
gl.uniform1i(clickedFace, pixel[3])
}
draw()
})
效果:
好像正确了,但是点击1面下半部分的时候,显示的却是点击的3.
原因是因为我们将鼠标点击位置转化为readPixel
的前两个参数时有问题。
根据opengl
对于readPixel
的规定,
readPixel
是从当前颜色缓冲区的左下角开始获取的(因为纹理坐标通常是反过来的),可以复习一下。
所以,我们转化坐标的时候一定得注意,只需要改动这一句
-const canvasY = clientY - top
+const canvasY = bottom - clientY
现在就可以看看效果了