一张灰度叶片加上一点方向光,就会像压在纸里的浅浮雕。这个效果很好玩,但它没有从照片里恢复真实三维。更准确的说法是:把灰度当作人为指定的高度,再由邻域差分构造一张伪法线图。
从高度差开始
Sobel 的横、纵卷积核分别估计两个方向的变化。下面将结果除以 8,使单位斜坡的内点梯度接近 1,再乘上强度参数。法线取(-gx,-gy,1)后归一化,和同样归一化、从表面指向光源的光向量点乘,得到一个简化漫反射亮度。
内图黄色箭头画的是入射光线,与公式中从表面指向光源的 L 方向相反。坐标方向必须说清楚:图像 y 轴向下,光向量也用同一坐标约定。若一边把 y 当屏幕向下,另一边却当世界向上,阴影就会翻转。边界使用钳位采样,不会读取越界数据,但会造成边缘梯度和内点不同。
Canvas 像素接口可以接收最终像素数组。本例刻意先保留一维高度数组,不把图像读取、色彩管理和卷积混在一起。输入若来自 sRGB 照片,还需要决定灰度与高度的映射,不能默认亮的地方一定凸起。
用平面检查方向
纯常量图应该得到朝向正 z 的法线;沿 x 递增的平面,在内点应该得到负 x 分量。这样的例子比“看起来挺立体”更能暴露符号错误。下面生成一个高斯形小丘,只用于观看明暗;它不是植物组织模型,也没有环境遮蔽、镜面反射和投影。
运行node relief.js输出relief.pgm。本地测试覆盖常量、斜坡、单位法线和边界索引,没有测试真实照片、GPU、浏览器上传与性能。把每一步留成独立函数,后面想换高度图或光照,才不会同时打破所有环节。
可独立运行的核心例子
const fs=require('node:fs'); function normalAt(a,w,h,x,y,strength=1) { const sample=(dx,dy)=>a[Math.max(0,Math.min(h-1,y+dy))*w+Math.max(0,Math.min(w-1,x+dx))]; const gx=(-sample(-1,-1)+sample(1,-1)-2*sample(-1,0)+2*sample(1,0)-sample(-1,1)+sample(1,1))/8; const gy=(-sample(-1,-1)-2*sample(0,-1)-sample(1,-1)+sample(-1,1)+2*sample(0,1)+sample(1,1))/8; const v=[-gx*strength,-gy*strength,1],length=Math.hypot(...v); return v.map(x=>x/length); } const flat=Array(25).fill(3),ramp=Array.from({length:25},(_,i)=>i%5); if(normalAt(flat,5,5,2,2).join(',')!=='0,0,1')throw Error('平面错误'); const n=normalAt(ramp,5,5,2,2); if(Math.abs(n[0]+1/Math.sqrt(2))>1e-9||Math.abs(Math.hypot(...n)-1)>1e-9)throw Error('梯度错误'); const w=64,h=64,a=Array.from({length:w*h},(_,i)=>10*Math.exp(-((i%w-32)**2+(Math.floor(i/w)-32)**2)/200)); const light=[-.5,-.5,1],ll=Math.hypot(...light),l=light.map(x=>x/ll); const pixels=a.map((_,i)=>{ const n=normalAt(a,w,h,i%w,Math.floor(i/w),2); const dot=n.reduce((sum,x,j)=>sum+x*l[j],0); return Math.round(255*(.2+.8*Math.max(0,dot))); }); if(pixels.some(x=>!Number.isFinite(x)||x<0||x>255))throw Error('灰度越界'); fs.writeFileSync('relief.pgm',`P2\n${w} ${h}\n255\n`+pixels.join(' ')); console.log('已生成 relief.pgm');内图借叶片说明流程;代码用高斯小丘验证法线,并未恢复叶片三维。