鸿蒙APP开发-带你走近分构App的Canvas3D

2 阅读7分钟

分构App Canvas3D功能技术解析

如果你对3D分子可视化感兴趣,可以去鸿蒙应用市场搜索"分构"下载体验一下。今天咱们聊聊这个App的Canvas3D功能。

写在前面

大家好,今天聊的分构App,是一个3D分子结构可视化工具。这个App的技术含量比较高,涉及到3D图形渲染、数学计算这些Web前端很少深入的领域。

Web端做3D,有Three.js、Babylon.js这些成熟的库。鸿蒙端没有直接对应的3D库,但通过Canvas 2D的变换矩阵,我们也能实现简单的3D效果。当然,如果要做复杂的3D场景,可能需要考虑OpenGL或Vulkan,但那些就超出本文的范围了。

今天这篇,我会用Canvas 2D来实现一个简单的3D引擎,展示分子结构。虽然是简化版的,但核心的3D变换原理是一样的。


1. 3D基础:坐标系与投影

3D渲染的第一步是理解坐标系和投影。

3D坐标变换:

// 3D点定义
interface Point3D {
  x: number;
  y: number;
  z: number;
}

interface Point2D {
  x: number;
  y: number;
}

class Transform3D {
  // 透视投影
  static perspective(point: Point3D, fov: number, distance: number): Point2D {
    const factor = fov / (distance + point.z);
    return {
      x: point.x * factor,
      y: point.y * factor
    };
  }

  // 绕X轴旋转
  static rotateX(point: Point3D, angle: number): Point3D {
    const cos = Math.cos(angle);
    const sin = Math.sin(angle);
    return {
      x: point.x,
      y: point.y * cos - point.z * sin,
      z: point.y * sin + point.z * cos
    };
  }

  // 绕Y轴旋转
  static rotateY(point: Point3D, angle: number): Point3D {
    const cos = Math.cos(angle);
    const sin = Math.sin(angle);
    return {
      x: point.x * cos + point.z * sin,
      y: point.y,
      z: -point.x * sin + point.z * cos
    };
  }

  // 绕Z轴旋转
  static rotateZ(point: Point3D, angle: number): Point3D {
    const cos = Math.cos(angle);
    const sin = Math.sin(angle);
    return {
      x: point.x * cos - point.y * sin,
      y: point.x * sin + point.y * cos,
      z: point.z
    };
  }

  // 平移
  static translate(point: Point3D, dx: number, dy: number, dz: number): Point3D {
    return {
      x: point.x + dx,
      y: point.y + dy,
      z: point.z + dz
    };
  }

  // 缩放
  static scale(point: Point3D, sx: number, sy: number, sz: number): Point3D {
    return {
      x: point.x * sx,
      y: point.y * sy,
      z: point.z * sz
    };
  }
}

2. 分子结构:数据模型

分子由原子和化学键组成。

分子数据结构:

interface Atom {
  id: string;
  element: string;
  position: Point3D;
  color: string;
  radius: number;
}

interface Bond {
  id: string;
  atom1Id: string;
  atom2Id: string;
  type: 'single' | 'double' | 'triple';
}

interface Molecule {
  id: string;
  name: string;
  atoms: Atom[];
  bonds: Bond[];
}

// 元素属性
const elementProperties: Record<string, { color: string; radius: number }> = {
  'H': { color: '#FFFFFF', radius: 0.31 },
  'C': { color: '#333333', radius: 0.77 },
  'N': { color: '#3050F8', radius: 0.75 },
  'O': { color: '#FF0D0D', radius: 0.73 },
  'S': { color: '#FFFF30', radius: 1.02 },
  'P': { color: '#FF8000', radius: 1.05 },
  'Cl': { color: '#1FF01F', radius: 0.99 },
  'Br': { color: '#A62929', radius: 1.14 }
};

// 示例分子:水
const waterMolecule: Molecule = {
  id: 'water',
  name: '水分子 (H₂O)',
  atoms: [
    { id: 'O1', element: 'O', position: { x: 0, y: 0, z: 0 }, ...elementProperties['O'] },
    { id: 'H1', element: 'H', position: { x: -0.96, y: 0, z: 0 }, ...elementProperties['H'] },
    { id: 'H2', element: 'H', position: { x: 0.24, y: 0.93, z: 0 }, ...elementProperties['H'] }
  ],
  bonds: [
    { id: 'b1', atom1Id: 'O1', atom2Id: 'H1', type: 'single' },
    { id: 'b2', atom1Id: 'O1', atom2Id: 'H2', type: 'single' }
  ]
};

3. 3D渲染:Canvas绘制分子

用Canvas 2D绘制3D分子结构。

ArkTS分子渲染器:

@Component
struct MoleculeRenderer {
  @State molecule: Molecule = waterMolecule;
  @State rotationX: number = 0;
  @State rotationY: number = 0;
  @State rotationZ: number = 0;
  @State scale: number = 100;
  @State isAutoRotating: boolean = false;

  private centerX: number = 150;
  private centerY: number = 200;
  private fov: number = 500;
  private distance: number = 5;

  build() {
    Column() {
      Canvas(this.drawMolecule)
        .width('90%')
        .height(400)
        .backgroundColor('#1a1a2e')
        .borderRadius(12)
        .margin({ top: 20 })

      // 控制面板
        Column() {
          Row() {
            Text('X轴旋转')
              .fontSize(14)
              .width(80)

            Slider({
              value: this.rotationX,
              min: -Math.PI,
              max: Math.PI,
              step: 0.01
            })
              .onChange((value: number) => {
                this.rotationX = value;
              })
          }

          Row() {
            Text('Y轴旋转')
              .fontSize(14)
              .width(80)

            Slider({
              value: this.rotationY,
              min: -Math.PI,
              max: Math.PI,
              step: 0.01
            })
              .onChange((value: number) => {
                this.rotationY = value;
              })
          }

          Row() {
            Text('缩放')
              .fontSize(14)
              .width(80)

            Slider({
              value: this.scale,
              min: 50,
              max: 200,
              step: 1
            })
              .onChange((value: number) => {
                this.scale = value;
              })
          }
        }
        .width('90%')
        .margin({ top: 16 })

      // 操作按钮
        Row() {
          Button(this.isAutoRotating ? '停止旋转' : '自动旋转')
            .onClick(() => this.toggleAutoRotation())

          Button('重置视角')
            .margin({ left: 12 })
            .onClick(() => this.resetView())
        }
        .margin({ top: 16 })
    }
  }

  drawMolecule = (ctx: CanvasRenderingContext2D) => {
    const width = 300;
    const height = 400;

    // 清空画布
    ctx.clearRect(0, 0, width, height);

    // 变换原子位置
    const transformedAtoms = this.molecule.atoms.map(atom => {
      let pos = { ...atom.position };

      // 应用旋转变换
      pos = Transform3D.rotateX(pos, this.rotationX);
      pos = Transform3D.rotateY(pos, this.rotationY);
      pos = Transform3D.rotateZ(pos, this.rotationZ);

      // 应用缩放
      pos = Transform3D.scale(pos, this.scale, this.scale, this.scale);

      // 透视投影
      const projected = Transform3D.perspective(pos, this.fov, this.distance);

      return {
        ...atom,
        screenX: projected.x + this.centerX,
        screenY: projected.y + this.centerY,
        depth: pos.z
      };
    });

    // 按深度排序(远的先画)
    const sortedAtoms = [...transformedAtoms].sort((a, b) => a.depth - b.depth);

    // 绘制化学键
    this.molecule.bonds.forEach(bond => {
      const atom1 = transformedAtoms.find(a => a.id === bond.atom1Id);
      const atom2 = transformedAtoms.find(a => a.id === bond.atom2Id);

      if (atom1 && atom2) {
        this.drawBond(ctx, atom1, atom2, bond.type);
      }
    });

    // 绘制原子
    sortedAtoms.forEach(atom => {
      this.drawAtom(ctx, atom);
    });
  }

  drawAtom(ctx: CanvasRenderingContext2D, atom: any) {
    // 绘制阴影
    ctx.beginPath();
    ctx.arc(atom.screenX + 2, atom.screenY + 2, atom.radius * this.scale * 0.3, 0, Math.PI * 2);
    ctx.fillStyle = 'rgba(0, 0, 0, 0.3)';
    ctx.fill();

    // 绘制原子
    ctx.beginPath();
    ctx.arc(atom.screenX, atom.screenY, atom.radius * this.scale * 0.3, 0, Math.PI * 2);

    // 创建渐变效果
    const gradient = ctx.createRadialGradient(
      atom.screenX - 5, atom.screenY - 5, 0,
      atom.screenX, atom.screenY, atom.radius * this.scale * 0.3
    );
    gradient.addColorStop(0, this.lightenColor(atom.color, 50));
    gradient.addColorStop(1, atom.color);

    ctx.fillStyle = gradient;
    ctx.fill();

    // 绘制边框
    ctx.strokeStyle = this.darkenColor(atom.color, 30);
    ctx.lineWidth = 1;
    ctx.stroke();

    // 绘制元素符号
    ctx.fillStyle = '#fff';
    ctx.font = `${Math.max(10, atom.radius * this.scale * 0.2)}px Arial`;
    ctx.textAlign = 'center';
    ctx.textBaseline = 'middle';
    ctx.fillText(atom.element, atom.screenX, atom.screenY);
  }

  drawBond(ctx: CanvasRenderingContext2D, atom1: any, atom2: any, type: string) {
    const dx = atom2.screenX - atom1.screenX;
    const dy = atom2.screenY - atom1.screenY;

    ctx.strokeStyle = '#666';
    ctx.lineWidth = 2;

    switch (type) {
      case 'single':
        ctx.beginPath();
        ctx.moveTo(atom1.screenX, atom1.screenY);
        ctx.lineTo(atom2.screenX, atom2.screenY);
        ctx.stroke();
        break;

      case 'double':
        const offset = 3;
        const nx = -dy / Math.sqrt(dx * dx + dy * dy) * offset;
        const ny = dx / Math.sqrt(dx * dx + dy * dy) * offset;

        ctx.beginPath();
        ctx.moveTo(atom1.screenX + nx, atom1.screenY + ny);
        ctx.lineTo(atom2.screenX + nx, atom2.screenY + ny);
        ctx.stroke();

        ctx.beginPath();
        ctx.moveTo(atom1.screenX - nx, atom1.screenY - ny);
        ctx.lineTo(atom2.screenX - nx, atom2.screenY - ny);
        ctx.stroke();
        break;

      case 'triple':
        // 类似double,但多一条线
        break;
    }
  }

  lightenColor(color: string, amount: number): string {
    // 简化实现
    return color;
  }

  darkenColor(color: string, amount: number): string {
    // 简化实现
    return color;
  }

  toggleAutoRotation() {
    this.isAutoRotating = !this.isAutoRotating;

    if (this.isAutoRotating) {
      this.autoRotate();
    }
  }

  autoRotate() {
    if (!this.isAutoRotating) return;

    this.rotationY += 0.02;

    setTimeout(() => this.autoRotate(), 16);
  }

  resetView() {
    this.rotationX = 0;
    this.rotationY = 0;
    this.rotationZ = 0;
    this.scale = 100;
  }
}

4. 触摸交互:手势控制

用户可以通过手势来旋转和缩放分子。

ArkTS手势处理:

@Component
struct InteractiveMolecule {
  @State rotationX: number = 0;
  @State rotationY: number = 0;
  @State scale: number = 1;
  @State lastTouchX: number = 0;
  @State lastTouchY: number = 0;
  @State lastPinchDistance: number = 0;

  build() {
    Column() {
      MoleculeRenderer({
        rotationX: this.rotationX,
        rotationY: this.rotationY,
        scale: this.scale * 100
      })
    }
    .onTouch((event: TouchEvent) => {
      this.handleTouch(event);
    })
  }

  handleTouch(event: TouchEvent) {
    switch (event.type) {
      case TouchType.Down:
        if (event.touches.length === 1) {
          this.lastTouchX = event.touches[0].x;
          this.lastTouchY = event.touches[0].y;
        } else if (event.touches.length === 2) {
          this.lastPinchDistance = this.getPinchDistance(event.touches);
        }
        break;

      case TouchType.Move:
        if (event.touches.length === 1) {
          // 单指旋转
          const dx = event.touches[0].x - this.lastTouchX;
          const dy = event.touches[0].y - this.lastTouchY;

          this.rotationY += dx * 0.01;
          this.rotationX += dy * 0.01;

          this.lastTouchX = event.touches[0].x;
          this.lastTouchY = event.touches[0].y;
        } else if (event.touches.length === 2) {
          // 双指缩放
          const currentDistance = this.getPinchDistance(event.touches);
          const scaleChange = currentDistance / this.lastPinchDistance;

          this.scale *= scaleChange;
          this.scale = Math.max(0.5, Math.min(3, this.scale));

          this.lastPinchDistance = currentDistance;
        }
        break;
    }
  }

  getPinchDistance(touches: TouchObject[]): number {
    const dx = touches[1].x - touches[0].x;
    const dy = touches[1].y - touches[0].y;
    return Math.sqrt(dx * dx + dy * dy);
  }
}

5. 分子库:预设分子

App内置了一些常见分子供用户学习。

分子数据库:

const moleculeDatabase: Molecule[] = [
  // 水 H₂O
  {
    id: 'water',
    name: '水 (H₂O)',
    atoms: [
      { id: 'O', element: 'O', position: { x: 0, y: 0, z: 0 }, color: '#FF0D0D', radius: 0.73 },
      { id: 'H1', element: 'H', position: { x: -0.96, y: 0, z: 0 }, color: '#FFFFFF', radius: 0.31 },
      { id: 'H2', element: 'H', position: { x: 0.24, y: 0.93, z: 0 }, color: '#FFFFFF', radius: 0.31 }
    ],
    bonds: [
      { id: 'b1', atom1Id: 'O', atom2Id: 'H1', type: 'single' },
      { id: 'b2', atom1Id: 'O', atom2Id: 'H2', type: 'single' }
    ]
  },

  // 二氧化碳 CO₂
  {
    id: 'co2',
    name: '二氧化碳 (CO₂)',
    atoms: [
      { id: 'C', element: 'C', position: { x: 0, y: 0, z: 0 }, color: '#333333', radius: 0.77 },
      { id: 'O1', element: 'O', position: { x: -1.16, y: 0, z: 0 }, color: '#FF0D0D', radius: 0.73 },
      { id: 'O2', element: 'O', position: { x: 1.16, y: 0, z: 0 }, color: '#FF0D0D', radius: 0.73 }
    ],
    bonds: [
      { id: 'b1', atom1Id: 'C', atom2Id: 'O1', type: 'double' },
      { id: 'b2', atom1Id: 'C', atom2Id: 'O2', type: 'double' }
    ]
  },

  // 甲烷 CH₄
  {
    id: 'methane',
    name: '甲烷 (CH₄)',
    atoms: [
      { id: 'C', element: 'C', position: { x: 0, y: 0, z: 0 }, color: '#333333', radius: 0.77 },
      { id: 'H1', element: 'H', position: { x: 0.63, y: 0.63, z: 0.63 }, color: '#FFFFFF', radius: 0.31 },
      { id: 'H2', element: 'H', position: { x: -0.63, y: -0.63, z: 0.63 }, color: '#FFFFFF', radius: 0.31 },
      { id: 'H3', element: 'H', position: { x: -0.63, y: 0.63, z: -0.63 }, color: '#FFFFFF', radius: 0.31 },
      { id: 'H4', element: 'H', position: { x: 0.63, y: -0.63, z: -0.63 }, color: '#FFFFFF', radius: 0.31 }
    ],
    bonds: [
      { id: 'b1', atom1Id: 'C', atom2Id: 'H1', type: 'single' },
      { id: 'b2', atom1Id: 'C', atom2Id: 'H2', type: 'single' },
      { id: 'b3', atom1Id: 'C', atom2Id: 'H3', type: 'single' },
      { id: 'b4', atom1Id: 'C', atom2Id: 'H4', type: 'single' }
    ]
  },

  // 乙醇 C₂H₅OH
  {
    id: 'ethanol',
    name: '乙醇 (C₂H₅OH)',
    atoms: [
      { id: 'C1', element: 'C', position: { x: -0.75, y: 0, z: 0 }, color: '#333333', radius: 0.77 },
      { id: 'C2', element: 'C', position: { x: 0.75, y: 0, z: 0 }, color: '#333333', radius: 0.77 },
      { id: 'O', element: 'O', position: { x: 1.5, y: 1.0, z: 0 }, color: '#FF0D0D', radius: 0.73 },
      { id: 'H1', element: 'H', position: { x: -1.2, y: 0.9, z: 0 }, color: '#FFFFFF', radius: 0.31 },
      { id: 'H2', element: 'H', position: { x: -1.2, y: -0.9, z: 0 }, color: '#FFFFFF', radius: 0.31 },
      { id: 'H3', element: 'H', position: { x: -1.2, y: 0, z: 0.9 }, color: '#FFFFFF', radius: 0.31 },
      { id: 'H4', element: 'H', position: { x: 1.0, y: -0.9, z: 0 }, color: '#FFFFFF', radius: 0.31 },
      { id: 'H5', element: 'H', position: { x: 2.3, y: 1.0, z: 0 }, color: '#FFFFFF', radius: 0.31 }
    ],
    bonds: [
      { id: 'b1', atom1Id: 'C1', atom2Id: 'C2', type: 'single' },
      { id: 'b2', atom1Id: 'C2', atom2Id: 'O', type: 'single' },
      { id: 'b3', atom1Id: 'C1', atom2Id: 'H1', type: 'single' },
      { id: 'b4', atom1Id: 'C1', atom2Id: 'H2', type: 'single' },
      { id: 'b5', atom1Id: 'C1', atom2Id: 'H3', type: 'single' },
      { id: 'b6', atom1Id: 'C2', atom2Id: 'H4', type: 'single' },
      { id: 'b7', atom1Id: 'O', atom2Id: 'H5', type: 'single' }
    ]
  }
];

总结

分构App的Canvas3D功能,通过Canvas 2D实现了简单的3D分子可视化。虽然没有专业的3D引擎,但核心的坐标变换、透视投影、深度排序这些概念都涵盖了。

如果你对3D编程感兴趣,建议从2D Canvas开始,理解了基本的数学原理后,再转向专业的3D库会更容易上手。

分构App就聊到这里。下一篇文章,我会聊聊分构App的分子数据管理功能。