鸿蒙PC平台 PyRayTrace 光线追踪渲染器适配实战:基于 Electron 壳方案的纯 JavaScript Whitted 风格递归反射引擎开发

PyRayTrace 是一款纯 JavaScript 实现的光线追踪渲染器,基于经典 Whitted 风格算法,支持直接光照、阴影射线、递归反射与 Halton 低差异序列抗锯齿。本文记录在鸿蒙 PC 平台上,基于 Electron 壳方案从零构建一个具备渐进式逐行渲染、多场景切换、可调参数面板与 PNG 导出的光线追踪渲染器的完整过程——涵盖三维向量运算、射线-几何体求交、材质系统、相机模型、渐进式渲染调度、鸿蒙稳定性适配等核心功能的实现细节与踩坑经验。

欢迎加入开源鸿蒙 PC 社区:https://harmonypc.csdn.net/

欢迎在 PC 社区平台申请新建项目:https://atomgit.com/OpenHarmonyPCDeveloper

AtomGit 仓库地址:https://atomgit.com/OpenHarmonyPCDeveloper/ohos_RayTracePy

一、技术架构分析

1.1 整体架构

本项目采用 Electron Web 层 + 鸿蒙 HAP 壳工程的分层架构:

  • Electron Web 层:运行于 ArkWeb 引擎,提供完整的光线追踪计算、Canvas 逐行渲染、参数交互与图像导出能力
  • 鸿蒙 HAP 壳工程:通过 web_engine 模块加载 Web 应用,提供窗口管理、文件系统访问、屏幕自适应等系统能力

这种架构的核心优势在于:业务代码与平台完全解耦,Electron 部分可以在任何平台独立运行,鸿蒙壳仅负责适配和打包。光线追踪引擎为纯 JavaScript 实现,不依赖任何第三方库或 WebGL,所有渲染通过 Canvas 2D API 逐行写入像素数据。

1.2 技术方案

对比项Python 原版(py-raytrace)鸿蒙适配版(PyRayTrace)
渲染算法Whitted 风格光线追踪同算法 JavaScript 移植
采样方式Halton 低差异序列同算法 JavaScript 实现
渲染输出文件保存Canvas 2D 逐行渐进式渲染
开发语言PythonHTML5/Canvas/Vanilla JavaScript
文件操作本地文件 I/ONode.js fs 模块 + IPC
部署方式pip 安装运行HAP 包(鸿蒙应用)
运行环境Windows/macOS/Linux鸿蒙 PC(ArkWeb 引擎)

1.3 核心功能清单

功能模块具体能力
光线追踪引擎三维向量运算、射线-球体/平面求交、递归反射、阴影射线
材质系统Lambert 漫反射、Metal 金属反射(含 fuzz 模糊)、Emissive 自发光
预设场景经典三球、金属矩阵(3×3 Halton 抖动)、光池夜景(自发光照明)
渐进式渲染逐行渲染 + 分批让出主线程,保持 UI 实时可交互
抗锯齿Halton(2,3) 低差异序列亚像素采样,1/2/4/9 采样可选
参数面板分辨率、采样数、反射深度、视场角 FOV、每帧行数实时调节
PNG 导出Canvas → Base64 → Node.js fs 真实文件写入
实时统计射线总数、射线/秒速度、渲染耗时、进度百分比
鸿蒙稳定自定义关于弹窗 + 三防窗口配置 + 禁用原生菜单

二、环境准备

2.1 开发环境要求

项目版本/信息
操作系统Windows 10/11
核心框架Electron (Node.js + Chromium)
技术栈HTML5/Canvas 2D/Vanilla JavaScript
渲染引擎纯 JavaScript 自研光线追踪
目标设备鸿蒙 PC
目标架构arm64-v8a
开发工具DevEco Studio(鸿蒙官方 IDE)

2.2 项目结构

ohos_hap/
├── electron-apps/
│   └── PyRayTrace/                    # PyRayTrace 应用(开发目录)
│       ├── package.json               # 项目配置
│       ├── main.js                    # 主进程(窗口管理 + 图像导出 IPC)
│       ├── index.html                 # 页面布局(工具栏 + 参数面板 + 画布 + 状态栏)
│       ├── renderer.js                # 渲染进程(参数交互 + 渐进式渲染调度 + PNG 导出)
│       ├── raytrace-engine.js         # 光线追踪引擎(向量运算 + 求交 + 材质 + 场景)
│       └── styles/
│           └── raytrace.css           # Catppuccin Mocha 深色主题样式
├── web_engine/                        # 鸿蒙壳工程
│   └── src/main/resources/resfile/resources/app/  # 部署目录
└── electron/                          # Electron 壳

2.3 依赖说明

本项目零第三方库依赖,所有功能均由纯 JavaScript 手写实现:

模块用途
raytrace-engine.js光线追踪引擎:向量运算、射线求交、材质、场景
renderer.js渲染进程:UI 交互、渲染调度、PNG 导出
Canvas 2D API逐行写入 ImageData 像素数据
Node.js fs通过 IPC 将 Base64 图像写入本地 PNG 文件

三、核心适配流程

3.1 主进程实现(main.js)

主进程负责窗口创建和图像导出 IPC。鸿蒙平台需要特殊的稳定性配置。

// PyRayTrace - 主进程
var electron = require('electron');
var app = electron.app;
var BrowserWindow = electron.BrowserWindow;
var screen = electron.screen;
var ipcMain = electron.ipcMain;
var dialog = electron.dialog;
var Menu = electron.Menu;
var fs = require('fs');

// 防 GPU 白屏:禁用硬件加速
app.disableHardwareAcceleration();

var mainWindow = null;

function createWindow() {
  try {
    // 移除默认菜单栏
    Menu.setApplicationMenu(null);

    var display = screen.getPrimaryDisplay();
    mainWindow = new BrowserWindow({
      width: Math.floor(display.workAreaSize.width * 0.92),
      height: Math.floor(display.workAreaSize.height * 0.88),
      frame: true,
      transparent: false,
      resizable: true,
      webPreferences: { nodeIntegration: true, contextIsolation: false }
    });
    mainWindow.loadFile('index.html');
  } catch (e) {
    console.warn('[PyRayTrace] 创建窗口失败:', e.message);
  }
}

app.whenReady().then(function() { createWindow(); });
app.on('window-all-closed', function() { app.quit(); });

关键配置说明:

  • disableHardwareAcceleration:禁用硬件加速,避免鸿蒙 GPU 渲染白屏
  • Menu.setApplicationMenu(null):移除默认菜单栏,鸿蒙上自定义 UI 更可控
  • frame: true + transparent: false + resizable: true:鸿蒙三防配置,防止 XComponent 创建超时导致 SIGABRT 崩溃
  • try-catch 包裹窗口创建:防止单点故障导致整个进程退出

IPC 通道注册:

主进程注册了 2 个 IPC 通道供渲染进程调用,分别用于弹出保存对话框和写入 PNG 文件:

// 弹出图像保存对话框
ipcMain.handle('image:saveDialog', function() {
  return dialog.showSaveDialog(mainWindow, {
    title: '导出渲染图像',
    defaultPath: 'raytrace.png',
    filters: [{ name: 'PNG 图像', extensions: ['png'] }]
  });
});

// 将 Base64 数据写入 PNG 文件
ipcMain.handle('image:save', function(event, filePath, dataUrl) {
  try {
    var base64 = dataUrl.replace(/^data:image\/png;base64,/, '');
    var buffer = Buffer.from(base64, 'base64');
    fs.writeFileSync(filePath, buffer);
    return { success: true, size: buffer.length };
  } catch (e) {
    return { success: false, error: e.message };
  }
});

3.2 页面布局实现(index.html)

页面采用四段式布局:顶部工具栏 + 左侧参数面板 + 中央渲染画布 + 底部进度条与状态栏。

工具栏设计: 包含品牌标识、场景切换按钮、渲染/停止/导出/关于操作按钮。

<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <title>PyRayTrace - 光线追踪渲染器</title>
  <link rel="stylesheet" href="styles/raytrace.css">
</head>
<body>
  <!-- 顶部工具栏 -->
  <div class="toolbar">
    <div class="toolbar-brand">PyRayTrace</div>
    <div class="toolbar-separator"></div>
    <div class="toolbar-group">
      <span class="toolbar-label">场景</span>
      <button class="scene-btn active" data-scene="classic">经典三球</button>
      <button class="scene-btn" data-scene="metalGrid">金属矩阵</button>
      <button class="scene-btn" data-scene="lightPool">光池夜景</button>
    </div>
    <div class="toolbar-spacer"></div>
    <div class="toolbar-group">
      <button class="btn-primary" id="btnRender">▶ 渲染</button>
      <button class="btn-danger" id="btnStop" disabled>■ 停止</button>
      <button class="tool-btn" id="btnExport" title="导出 PNG" disabled>💾 导出</button>
      <button class="tool-btn" id="btnAbout" title="关于">ℹ 关于</button>
    </div>
  </div>

  <!-- 主体区域 -->
  <div class="main">
    <!-- 左侧参数面板 -->
    <div class="sidebar" id="sidebar">
      <div class="sidebar-header">
        <span class="sidebar-title">渲染参数</span>
      </div>
      <div class="sidebar-content">
        <div class="param-group">
          <label class="param-label">分辨率</label>
          <div class="param-options" id="resOptions">
            <button class="param-btn" data-res="320x240">320×240</button>
            <button class="param-btn active" data-res="480x360">480×360</button>
            <button class="param-btn" data-res="640x480">640×480</button>
          </div>
        </div>
        <div class="param-group">
          <label class="param-label">每像素采样(抗锯齿)</label>
          <div class="param-options" id="sampleOptions">
            <button class="param-btn" data-samples="1">1</button>
            <button class="param-btn active" data-samples="2">2</button>
            <button class="param-btn" data-samples="4">4</button>
            <button class="param-btn" data-samples="9">9</button>
          </div>
        </div>
        <div class="param-group">
          <label class="param-label">最大反射深度 <span class="param-value" id="depthValue">4</span></label>
          <input type="range" id="depthSlider" class="param-slider" min="1" max="8" step="1" value="4">
        </div>
        <div class="param-group">
          <label class="param-label">视场角 FOV <span class="param-value" id="fovValue">60°</span></label>
          <input type="range" id="fovSlider" class="param-slider" min="30" max="100" step="5" value="60">
        </div>
        <div class="param-group">
          <label class="param-label">每帧行数(流畅度) <span class="param-value" id="batchValue">6</span></label>
          <input type="range" id="batchSlider" class="param-slider" min="1" max="24" step="1" value="6">
        </div>
      </div>
      <!-- 场景信息 -->
      <div class="sidebar-header">
        <span class="sidebar-title">场景信息</span>
      </div>
      <div class="sidebar-content" id="sceneInfo">
        <div class="info-row"><span class="info-key">名称</span><span class="info-val" id="infoName">经典三球</span></div>
        <div class="info-row"><span class="info-key">物体数</span><span class="info-val" id="infoObjects">5</span></div>
        <div class="info-row"><span class="info-key">光源数</span><span class="info-val" id="infoLights">2</span></div>
        <div class="info-row"><span class="info-key">相机</span><span class="info-val" id="infoCamera">(-4.5, 2.8, 5.5)</span></div>
      </div>
    </div>

    <!-- 渲染画布区域 -->
    <div class="viewport">
      <div class="canvas-wrap" id="canvasWrap">
        <canvas id="renderCanvas" width="480" height="360"></canvas>
        <div class="canvas-empty" id="canvasEmpty">
          <div class="empty-logo">PyRayTrace</div>
          <div class="empty-subtitle">光线追踪渲染器</div>
          <div class="empty-hint">选择场景后点击「渲染」开始逐行绘制</div>
        </div>
      </div>
    </div>
  </div>

  <!-- 底部进度区 -->
  <div class="progress-panel">
    <div class="progress-bar-wrap">
      <div class="progress-bar" id="progressBar"></div>
    </div>
    <div class="progress-text" id="progressText">就绪</div>
  </div>

  <!-- 底部状态栏 -->
  <div class="statusbar">
    <div class="status-left">
      <span class="status-item" id="statusScene">场景: 经典三球</span>
      <span class="status-sep">|</span>
      <span class="status-item" id="statusRes">480×360</span>
    </div>
    <div class="status-right">
      <span class="status-item" id="statusRays">射线: 0</span>
      <span class="status-sep">|</span>
      <span class="status-item" id="statusSpeed">0 射线/秒</span>
      <span class="status-sep">|</span>
      <span class="status-item" id="statusTime">耗时 0.0s</span>
    </div>
  </div>

  <script src="raytrace-engine.js"></script>
  <script src="renderer.js"></script>
</body>
</html>

布局说明:

  • 左侧参数面板固定宽度 250px,包含分辨率/采样数按钮组、反射深度/FOV/每帧行数滑块
  • 中央画布区域自适应填充,Canvas 使用 image-rendering: pixelated 保持像素锐利
  • 底部进度条实时显示渲染百分比,状态栏显示射线数、速度、耗时
  • 关于弹窗使用纯 div + overlay 实现,避免原生弹窗崩溃

3.3 光线追踪引擎(raytrace-engine.js)

光线追踪引擎是整个应用的核心,完全用纯 JavaScript 实现,不依赖任何第三方库。引擎包含向量运算、射线定义、几何体求交、材质系统、光照追踪、相机模型和预设场景七大模块。

三维向量运算:

所有三维几何运算基于手写 vec3 函数族,包含加减乘、点积、叉积、归一化、反射等基础操作:

// 向量构造与基础运算
function vec3(x, y, z) { return { x: x || 0, y: y || 0, z: z || 0 }; }
function vAdd(a, b) { return vec3(a.x + b.x, a.y + b.y, a.z + b.z); }
function vSub(a, b) { return vec3(a.x - b.x, a.y - b.y, a.z - b.z); }
function vMul(a, s) { return vec3(a.x * s, a.y * s, a.z * s); }
function vDot(a, b) { return a.x * b.x + a.y * b.y + a.z * b.z; }
function vCross(a, b) {
  return vec3(a.y * b.z - a.z * b.y, a.z * b.x - a.x * b.z, a.x * b.y - a.y * b.x);
}
function vLen(a) { return Math.sqrt(vDot(a, a)); }
function vNorm(a) {
  var len = vLen(a);
  if (len === 0) return vec3(0, 0, 0);
  return vec3(a.x / len, a.y / len, a.z / len);
}
function vReflect(v, n) { return vSub(v, vMul(n, 2 * vDot(v, n))); }

Halton 低差异序列(确定性采样):

抗锯齿使用 Halton 序列生成亚像素偏移,相比随机采样具有更好的均匀性和可复现性:

function halton(index, base) {
  var f = 1, r = 0;
  while (index > 0) {
    f = f / base;
    r = r + f * (index % base);
    index = Math.floor(index / base);
  }
  return r;
}

材质系统:

引擎支持三种材质类型——Lambert 漫反射(含 Blinn-Phong 高光)、Metal 金属反射(含 fuzz 模糊)、Emissive 自发光:

// 漫反射材质
function makeLambert(albedo, specular, shininess) {
  return { type: 'lambert', albedo: albedo, specular: specular || 0.1, shininess: shininess || 32 };
}

// 金属材质:reflectivity 反射率 0~1,fuzz 模糊度
function makeMetal(albedo, reflectivity, fuzz) {
  return { type: 'metal', albedo: albedo, reflectivity: reflectivity, fuzz: fuzz || 0 };
}

// 自发光材质(用于光源球体)
function makeEmissive(emission) {
  return { type: 'emissive', albedo: emission, emission: emission };
}

射线-几何体求交:

支持球体(二次方程判别式法)和平面(法线点积法)两种几何体,intersectScene 遍历所有物体返回最近交点:

// 射线与球体求交(二次方程判别式法)
function intersectSphere(ray, sphere) {
  var oc = vSub(ray.origin, sphere.center);
  var a = vDot(ray.direction, ray.direction);
  var b = 2 * vDot(oc, ray.direction);
  var c = vDot(oc, oc) - sphere.radius * sphere.radius;
  var disc = b * b - 4 * a * c;
  if (disc < 0) return null;
  var sq = Math.sqrt(disc);
  var t1 = (-b - sq) / (2 * a);
  var t2 = (-b + sq) / (2 * a);
  var t = t1 > 1e-4 ? t1 : (t2 > 1e-4 ? t2 : -1);
  if (t < 0) return null;
  var point = rayAt(ray, t);
  var normal = vNorm(vSub(point, sphere.center));
  return makeHit(t, point, normal, sphere.material);
}

// 射线与平面求交
function intersectPlane(ray, plane) {
  var denom = vDot(plane.normal, ray.direction);
  if (Math.abs(denom) < 1e-6) return null;
  var t = vDot(vSub(plane.point, ray.origin), plane.normal) / denom;
  if (t < 1e-4) return null;
  return makeHit(t, rayAt(ray, t), plane.normal, plane.material);
}

递归光照追踪(traceRay):

核心追踪函数实现 Whitted 风格算法:直接光照(Lambert 漫反射 + Blinn-Phong 高光)+ 阴影射线测试 + 递归反射(金属材质),自发光体不遮挡光线:

function traceRay(scene, ray, depth, stats) {
  stats.rays++;
  var hit = intersectScene(ray, scene.objects);
  if (!hit) return skyColor(scene, ray.direction);

  var mat = hit.material;
  if (mat.type === 'emissive') return mat.emission;

  var normal = vDot(hit.normal, ray.direction) < 0 ? hit.normal : vNeg(hit.normal);
  var viewDir = vNeg(ray.direction);
  var color = vec3(0, 0, 0);

  // 直接光照:遍历所有点光源
  for (var i = 0; i < scene.lights.length; i++) {
    var light = scene.lights[i];
    if (inShadow(scene, vAdd(hit.point, vMul(normal, 1e-3)), light.position)) continue;

    var lightDir = vNorm(vSub(light.position, hit.point));
    var intensity = light.intensity * Math.max(0, vDot(normal, lightDir))
                    / (1 + 0.05 * vLen(vSub(light.position, hit.point)));
    color = vAdd(color, vMulV(vMul(mat.albedo, intensity), light.color));

    // Blinn-Phong 高光(半程向量)
    var halfVec = vNorm(vAdd(lightDir, viewDir));
    var spec = Math.pow(Math.max(0, vDot(normal, halfVec)), mat.shininess || 1);
    color = vAdd(color, vMul(light.color, spec * (mat.specular || 0) * intensity * 40));
  }

  // 环境光:取场景天空平均亮度,避免阴影区全黑
  color = vAdd(color, vMulV(mat.albedo, vec3(0.08, 0.08, 0.1)));

  // 递归反射(金属材质)
  if (mat.type === 'metal' && depth > 0) {
    var reflDir = vReflect(ray.direction, normal);
    // fuzz 模糊:沿 Halton 序列偏移反射方向(确定性)
    if (mat.fuzz > 0) {
      var jx = (halton(depth * 7 + stats.rays % 97, 2) - 0.5) * mat.fuzz;
      var jy = (halton(depth * 13 + stats.rays % 89, 3) - 0.5) * mat.fuzz;
      reflDir = vNorm(vAdd(reflDir, vec3(jx, jy, jx)));
    }
    var reflRay = makeRay(vAdd(hit.point, vMul(normal, 1e-3)), reflDir);
    var reflColor = traceRay(scene, reflRay, depth - 1, stats);
    color = vAdd(color, vMulV(vMul(reflColor, mat.reflectivity), mat.albedo));
  }

  return color;
}

预设场景:

引擎内置三个精心设计的场景,每个场景有独立的相机参数和物体排布:

场景特点
经典三球漫反射地面 + 漫反射红球 + 金属银球 + 金属金球 + 蓝色小球,双光源
金属矩阵3×3 Halton 抖动彩色金属球 + 镜面地面,三光源暗色调
光池夜景中央自发光球照明 + 环绕彩色漫反射球,暗环境氛围

3.4 渐进式渲染调度(renderer.js)

renderer.js 负责参数面板交互和渐进式渲染调度。核心设计思路是:每批渲染 batchSize 行像素后通过 setTimeout(0) 让出主线程,保持 UI 实时可响应,避免长时间阻塞导致界面冻结。

渲染启动与调度:

function startRender() {
  if (state.rendering) return;

  var cam = SCENE_CAMERA[state.scene];
  var scene = SCENES[state.scene].build();
  var camera = makeCamera(
    vec3(cam.pos[0], cam.pos[1], cam.pos[2]),
    vec3(cam.target[0], cam.target[1], cam.target[2]),
    state.fov,
    state.width / state.height
  );
  var renderer = makeRenderer(scene, camera, state.width, state.height, state.samples, state.maxDepth);

  job = {
    renderer: renderer,
    imgData: ctx.createImageData(state.width, state.height),
    row: 0,
    startTime: Date.now(),
    timer: null,
    cancelled: false
  };

  state.rendering = true;
  showEmpty(false);
  setControlsEnabled(false);
  progressBar.classList.remove('done');
  updateProgress();
  scheduleBatch();
}

// 渐进式调度:每批渲染 batchSize 行后让出主线程
function scheduleBatch() {
  job.timer = setTimeout(renderBatch, 0);
}

function renderBatch() {
  if (!job || job.cancelled) return;
  var w = job.renderer.width;
  var rows = Math.min(state.batchSize, job.renderer.height - job.row);

  for (var i = 0; i < rows; i++) {
    var py = job.row + i;
    var row = renderRow(job.renderer, py);
    for (var px = 0; px < w; px++) {
      var idx = (py * w + px) * 4;
      job.imgData.data[idx] = row[px * 3];
      job.imgData.data[idx + 1] = row[px * 3 + 1];
      job.imgData.data[idx + 2] = row[px * 3 + 2];
      job.imgData.data[idx + 3] = 255;
    }
  }
  job.row += rows;
  ctx.putImageData(job.imgData, 0, 0);
  updateProgress();

  if (job.row < job.renderer.height) {
    scheduleBatch();
  } else {
    finishRender();
  }
}

渐进式调度的关键设计:

  • setTimeout(renderBatch, 0):每批渲染完后放入宏任务队列,让浏览器有机会处理 UI 事件
  • batchSize 可调(1~24 行):小值 UI 更流畅但渲染慢,大值渲染快但 UI 可能卡顿
  • job.cancelled 标志:支持随时停止渲染,停止后显示已完成行数
  • 场景/参数变更时自动停止上一次渲染并重置 UI

3.5 PNG 图像导出

渲染完成后,通过 Canvas toDataURL 获取 Base64 编码数据,再经 IPC 传给主进程写入本地文件:

function exportImage() {
  if (state.rendering || !canvas) return;
  var dataUrl = canvas.toDataURL('image/png');
  ipcRenderer.invoke('image:saveDialog').then(function(result) {
    if (result && !result.canceled && result.filePath) {
      ipcRenderer.invoke('image:save', result.filePath, dataUrl).then(function(saveResult) {
        if (saveResult.success) {
          progressText.textContent = '已导出: ' + result.filePath + '(' +
            (saveResult.size / 1024).toFixed(1) + ' KB)';
        } else {
          progressText.textContent = '导出失败: ' + saveResult.error;
        }
      });
    }
  });
}

导出流程: Canvas → toDataURL(‘image/png’) → IPC image:saveDialog(弹出保存对话框)→ IPC image:save(Base64 解码 + fs.writeFileSync 写入文件)。

3.6 Catppuccin Mocha 深色主题

整个 UI 采用 Catppuccin Mocha 配色方案,通过 CSS 变量统一管理 20+ 颜色令牌:

:root {
  --base: #1e1e2e;
  --mantle: #181825;
  --crust: #11111b;
  --surface0: #313244;
  --text: #cdd6f4;
  --blue: #89b4fa;
  --green: #a6e3a1;
  --red: #f38ba8;
  --mauve: #cba6f7;
  /* ... */
}

主题覆盖了工具栏、参数面板、画布区域、进度条、状态栏、关于弹窗等所有 UI 组件,确保视觉一致性。

四、鸿蒙稳定性适配

4.1 禁用默认菜单栏

问题现象: 应用启动后顶部出现系统菜单栏,与自定义工具栏重叠。

根因分析: Electron 默认会创建包含 File/Edit/View 等菜单项的应用菜单栏。在鸿蒙平台上,这个菜单栏不仅多余,还可能干扰自定义 UI 的布局。

解决方案: 在 createWindow 中调用 Menu.setApplicationMenu(null) 移除默认菜单栏,所有交互通过自定义工具栏实现。

Menu.setApplicationMenu(null);

4.2 自定义关于弹窗

问题现象: 点击关于按钮时如果使用原生 alert/dialog,应用崩溃。

根因分析: 原生 alert() 函数在鸿蒙平台上会创建模态 SubWindow,触发 XComponent 创建超时崩溃。

解决方案: 使用纯 div + overlay 实现关于弹窗,通过 CSS class 切换控制显示隐藏:

<div class="about-overlay" id="aboutOverlay">
  <div class="about-dialog">
    <div class="about-header">
      <span class="about-title">PyRayTrace</span>
      <button class="about-close" id="btnAboutClose">&times;</button>
    </div>
    <div class="about-body">
      <div class="about-version">v1.0.0</div>
      <div class="about-desc">纯 JavaScript 光线追踪渲染器,基于 Electron 壳方案适配 HarmonyOS</div>
      <!-- 开源声明 + 免责声明 -->
    </div>
  </div>
</div>
var aboutOverlay = document.getElementById('aboutOverlay');
document.getElementById('btnAbout').addEventListener('click', function() {
  aboutOverlay.classList.add('visible');
});
document.getElementById('btnAboutClose').addEventListener('click', function() {
  aboutOverlay.classList.remove('visible');
});
aboutOverlay.addEventListener('click', function(e) {
  if (e.target === aboutOverlay) aboutOverlay.classList.remove('visible');
});

4.3 鸿蒙三防窗口配置

主进程窗口创建遵循鸿蒙 Electron 壳方案的标准化配置:

配置项值作用
disableHardwareAccelerationtrue防止 GPU 渲染白屏
frametrue使用系统原生标题栏
transparentfalse禁用透明,防止渲染异常
resizabletrue允许窗口缩放
try-catch包裹 createWindow防止单点故障导致进程退出

五、构建与部署

在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

5.1 本地调试

在 electron-apps/PyRayTrace 目录下直接运行:

cd electron-apps/PyRayTrace
npm install electron --save-dev
npm start

5.2 部署到鸿蒙壳工程

将开发目录全量同步到 web_engine 部署目录:

# 清空部署目录
Remove-Item "web_engine/src/main/resources/resfile/resources/app/*" -Recurse -Force

# 复制应用文件
Copy-Item "electron-apps/PyRayTrace/*" "web_engine/src/main/resources/resfile/resources/app/" -Recurse -Force

5.3 构建 HAP 包

在 DevEco Studio 中打开项目,选择 Build → Build Hap(s)/APP(s) → Build Hap(s),即可生成可安装的 HAP 包。

六、常见问题与解决方案

Q1:改了 base/string.json 的应用名,设备上仍然显示旧名

问题现象: 将 electron/src/main/resources/base/element/string.json 中的 EntryAbility_label 改为 PyRayTrace 光线追踪渲染器后,重新构建安装到设备上,桌面图标下方仍然显示 Electron 壳工程的默认名称。

根因分析: 鸿蒙资源系统存在本地化目录优先级覆盖机制——en_US/element/string.json 和 zh_CN/element/string.json 中与 base 同名的 key 会覆盖 base 的值。壳工程模板中 en_US 和 zh_CN 的 EntryAbility_label 仍然是 Electron,设备根据系统语言匹配到对应本地化目录后,base 的修改被完全屏蔽。

解决: 必须同时修改三套资源文件,确保所有语言环境下名称一致:

// electron/src/main/resources/base/element/string.json
{
  "name": "EntryAbility_label",
  "value": "PyRayTrace 光线追踪渲染器"
}

// electron/src/main/resources/en_US/element/string.json
{
  "name": "EntryAbility_label",
  "value": "PyRayTrace 光线追踪渲染器"
}

// electron/src/main/resources/zh_CN/element/string.json
{
  "name": "EntryAbility_label",
  "value": "PyRayTrace 光线追踪渲染器"
}

教训: 鸿蒙资源覆盖规则是 locale目录 > base目录,修改应用名时必须全局 grep 所有 element/string.json 文件,不能只改 base。

Q2:构建签名报错 ERROR: 11014001 key alias not found

问题现象: 在 DevEco Studio 中执行 Build → Build Hap(s),SignHap 阶段报错 ERROR: 11014001,提示签名密钥别名找不到。

根因分析: build-profile.json5 中 signingConfigs.default.material.keyAlias 填写的值与 .p12 签名文件内实际存储的别名不一致。鸿蒙构建系统使用 DevEco 内置 JBR(JetBrains Runtime)解析密钥库,如果系统 JDK 版本与密钥库格式不兼容,也会导致解析失败。

解决: 使用 DevEco 内置 JBR 的 keytool 查看 p12 内真实别名:

# DevEco Studio 内置 JBR 路径(Windows)
"%DEVECO_JBR%\jbr\bin\keytool" -list -keystore "PyRayTrace.p12" -storepass 你的密码

查看输出中的别名名称(Alias Name),然后填入 build-profile.json5:

// build-profile.json5(节选)
"signingConfigs": [
  {
    "name": "default",
    "type": "HarmonyOS",
    "material": {
      "storeFile": "C:/path/to/PyRayTrace.p12",
      "keyAlias": "PyRayTrace1",   // ← 必须与 p12 内真实别名完全一致
      "signAlg": "SHA256withECDSA",
      "profile": "C:/path/to/PyRayTraceRelease.p7b",
      "certpath": "C:/path/to/PyRayTrace.cer"
    }
  }
]

也可以在 DevEco Studio 中通过 File → Project Structure → Signing Configs 界面查看和选择正确的别名。

Q3:AGC 审核驳回——快捷方式(shortcuts)非应用自身功能

问题现象: 提交 AGC 审核后收到驳回意见:module.json5 中声明了 ohos.ability.shortcuts 快捷方式,但该快捷方式并非应用自身功能入口,不符合上架规范。

根因分析: Electron 壳工程模板默认在 module.json5 的 metadata 中声明了 ohos.ability.shortcuts,并关联 shortcuts_config.json 配置文件。这些快捷方式指向的是壳工程的通用能力(如打开新窗口),而非 PyRayTrace 应用自身的功能入口,AGC 审核会将其识别为模板残留。

解决: 删除 module.json5 中的 shortcuts 元数据和对应的配置文件:

// 修改前:electron/src/main/module.json5
"metadata": [
  {
    "name": "ohos.ability.shortcuts",
    "value": "$profile:shortcuts_config"
  },
  {
    "name": "client_id",
    "value": ""
  }
]

// 修改后:移除 shortcuts 相关 metadata
"metadata": [
  {
    "name": "client_id",
    "value": ""
  }
]

同时删除 electron/src/main/resources/base/profile/shortcuts_config.json 文件。

Q4:修改了 electron-apps 下的代码,但 HAP 安装后没有生效

问题现象: 在 electron-apps/PyRayTrace/ 目录下修改了 renderer.js、index.html 等文件,重新构建 HAP 安装到设备后,应用行为没有任何变化。

根因分析: 鸿蒙壳工程的实际运行文件并不在 electron-apps/ 目录下,而是从 web_engine/src/main/resources/resfile/resources/app/ 部署目录加载。electron-apps/ 只是开发目录,修改后必须手动同步到部署目录才能进入签名包。这是一个非常容易忽略的双份文件问题。

解决: 每次修改 electron-apps/ 下的业务代码后,执行同步命令:

# 清空部署目录
Remove-Item "web_engine/src/main/resources/resfile/resources/app/*" -Recurse -Force

# 复制应用文件到部署目录
Copy-Item "electron-apps/PyRayTrace/*" "web_engine/src/main/resources/resfile/resources/app/" -Recurse -Force

验证方法: 同步后用文件哈希对比确认一致性:

# 对比源目录与部署目录的关键文件
$files = @("index.html","renderer.js","main.js","raytrace-engine.js","styles/raytrace.css")
foreach ($f in $files) {
    $src = Get-FileHash "electron-apps/PyRayTrace/$f" -Algorithm MD5
    $dst = Get-FileHash "web_engine/src/main/resources/resfile/resources/app/$f" -Algorithm MD5
    if ($src.Hash -eq $dst.Hash) { Write-Host "[OK] $f" }
    else { Write-Host "[MISMATCH] $f" -ForegroundColor Red }
}

Q5:AGC 审核驳回——requestPermissions 声明了受限 ACL 权限

问题现象: AGC 审核驳回,指出 web_engine/src/main/module.json5 中声明了 ohos.permission.FILE_ACCESS_PERSIST、ohos.permission.READ_WRITE_DESKTOP_DIRECTORY 等受限 ACL 权限,但应用隐私政策中未声明对应用途,且应用实际不使用这些能力。

根因分析: Electron 壳工程模板默认声明了大量系统权限,其中部分为受限 ACL 权限(需要额外审批)和 user_grant 权限(需要用户运行时授权)。PyRayTrace 作为纯渲染器,文件操作通过系统文件选择器完成(临时授权),不需要持久化文件访问、桌面目录读写、相机、麦克风、蓝牙等权限。

解决: 精简 web_engine/src/main/module.json5 的权限声明,只保留应用实际使用的 14 项系统权限:

// web_engine/src/main/module.json5(节选)
"requestPermissions": [
  { "name": "ohos.permission.INTERNET" },
  { "name": "ohos.permission.GET_NETWORK_INFO" },
  { "name": "ohos.permission.ACCESS_CERT_MANAGER" },
  { "name": "ohos.permission.RUNNING_LOCK" },
  { "name": "ohos.permission.PRINT" },
  { "name": "ohos.permission.PREPARE_APP_TERMINATE" },
  { "name": "ohos.permission.ACCESS_BIOMETRIC" },
  { "name": "ohos.permission.PRIVACY_WINDOW" },
  { "name": "ohos.permission.WINDOW_TOPMOST" },
  { "name": "ohos.permission.GYROSCOPE" },
  { "name": "ohos.permission.ACCELEROMETER" },
  { "name": "ohos.permission.SET_ABILITY_INSTANCE_INFO" },
  { "name": "ohos.permission.GET_FILE_ICON" },
  { "name": "ohos.permission.LOCK_WINDOW_CURSOR" }
]

移除的权限清单:

移除权限类型移除原因
FILE_ACCESS_PERSISTACL 受限文件操作通过系统选择器临时授权
READ_WRITE_DESKTOP_DIRECTORYACL 受限不直接访问桌面目录
READ_WRITE_DOCUMENTS_DIRECTORYACL 受限不直接访问文档目录
READ_WRITE_DOWNLOAD_DIRECTORYACL 受限不直接访问下载目录
READ_PASTEBOARDACL 受限不读取剪贴板
LOCATION / CAMERA / MICROPHONE 等user_grant渲染器不需要位置、相机、麦克风

Q6:渲染过程中界面冻结无响应

问题现象: 点击渲染按钮后,画面开始逐行绘制,但进度条不更新、停止按钮点击无反应、滑块拖动卡顿,整个界面像是卡死。

根因分析: 光线追踪是计算密集型任务。如果一次性渲染所有像素行,主线程会被 renderRow 的密集循环长时间阻塞(640×480 分辨率下可能需要数十秒),导致 UI 事件(进度条更新、按钮点击、滑块 input)无法被处理。

解决: 采用渐进式渲染调度,每批渲染 batchSize 行后通过 setTimeout(0) 让出主线程:

// 渐进式调度:每批渲染 batchSize 行后让出主线程,保持 UI 可响应
function scheduleBatch() {
  job.timer = setTimeout(renderBatch, 0);
}

function renderBatch() {
  if (!job || job.cancelled) return;
  var w = job.renderer.width;
  var rows = Math.min(state.batchSize, job.renderer.height - job.row);

  for (var i = 0; i < rows; i++) {
    var py = job.row + i;
    var row = renderRow(job.renderer, py);
    for (var px = 0; px < w; px++) {
      var idx = (py * w + px) * 4;
      job.imgData.data[idx] = row[px * 3];
      job.imgData.data[idx + 1] = row[px * 3 + 1];
      job.imgData.data[idx + 2] = row[px * 3 + 2];
      job.imgData.data[idx + 3] = 255;
    }
  }
  job.row += rows;
  ctx.putImageData(job.imgData, 0, 0);
  updateProgress();

  if (job.row < job.renderer.height) {
    scheduleBatch();  // 让出主线程后继续下一批
  } else {
    finishRender();
  }
}

batchSize 通过滑块暴露给用户调节(1~24 行),在渲染速度与 UI 流畅度之间取得平衡。

Q7:导出按钮点击无反应

问题现象: 渲染完成后,点击导出按钮没有任何反应,无法弹出文件保存对话框。

根因分析: setControlsEnabled 函数中对导出按钮的 disabled 属性赋值逻辑写反了。渲染完成调用 setControlsEnabled(true) 时,btnExport.disabled = true 反而把按钮禁用了:

// 错误代码
function setControlsEnabled(enabled) {
  document.getElementById('btnExport').disabled = enabled;  // ← 逻辑反了
}

解决: 将赋值改为 !enabled,使 enabled=true 时按钮可用:

// 修复后
function setControlsEnabled(enabled) {
  document.getElementById('btnRender').disabled = !enabled;
  document.getElementById('btnStop').disabled = enabled;
  document.getElementById('btnExport').disabled = !enabled;  // ← 修正逻辑
}

这个问题在标准 Electron 环境中同样会出现,属于逻辑笔误,但在渲染器场景下特别容易被忽略——因为开发者往往关注渲染引擎本身的正确性,而忽略了 UI 状态机的细节。

七、总结

本文完整记录了在鸿蒙 PC 平台上适配 PyRayTrace 光线追踪渲染器的全过程。通过 Electron 壳方案,我们实现了一个功能完整的纯 JavaScript 光线追踪渲染器,核心特性包括:

特性实现方案
光线追踪引擎纯 JavaScript Whitted 风格:直接光照 + 阴影 + 递归反射
材质系统Lambert 漫反射 + Metal 金属反射(fuzz 模糊)+ Emissive 自发光
抗锯齿Halton(2,3) 低差异序列亚像素采样,1/2/4/9 可选
渐进式渲染setTimeout 分批调度,每批 1~24 行可调,UI 始终可响应
预设场景经典三球 / 金属矩阵 / 光池夜景,各有独立相机参数
PNG 导出Canvas toDataURL + IPC + fs.writeFileSync 真实文件写入
实时统计射线总数 / 射线每秒 / 渲染耗时 / 进度百分比
鸿蒙稳定禁用默认菜单 + 自定义关于弹窗 + 三防窗口配置

适配过程中最关键的三个点:

  1. 渐进式渲染调度——光线追踪计算密集,必须分批让出主线程保持 UI 可交互,这是渲染类应用在鸿蒙上的核心挑战
  2. Halton 低差异序列——替代随机数用于抗锯齿和 fuzz 模糊,保证确定性、可复现、均匀分布
  3. 自定义弹窗替代原生 dialog——鸿蒙 ArkWeb 引擎上原生弹窗触发 SubWindow 崩溃,必须用纯 div 实现

这些问题在标准 Electron 环境中不会出现或表现不同,是鸿蒙 ArkWeb 引擎特有的兼容性挑战。通过系统性的排查和修复,最终实现了稳定可用的鸿蒙版纯 JavaScript 光线追踪渲染器。

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐