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