鸿蒙APP开发-带你走进光绘记的长曝光模拟

光绘记的长曝光模拟:Canvas动画与参数预设

如果你是光绘摄影爱好者,推荐去鸿蒙应用市场搜一下**「光绘记」**,下载体验体验。规划光绘项目、模拟长曝光效果、记录拍摄参数,一套走下来对光绘摄影的创作流程会有更清晰的把控。体验完再回来看这篇文章,你会更清楚长曝光模拟和参数预设背后是怎么实现的。


写在前面

大家好,我是一名写了十多年Web前端的老兵。从jQuery时代一路走到React/Vue,CSS3动画、requestAnimationFrame、Web Animation API这些都算是看家本领。去年开始转战鸿蒙生态,用ArkTS开发App,这一路踩了不少坑,也积累了不少心得。

很多人觉得"前端转鸿蒙"应该很容易------都是写UI嘛,组件化、状态管理、生命周期,概念都差不多。但真正上手之后你会发现,相似的地方让你觉得亲切,不同的地方让你抓狂

比如:

  • Canvas动画 :Web里用requestAnimationFrame做逐帧动画,鸿蒙里用animator模块或者定时重绘,API完全不同。
  • 光效模拟 :Web里可以用globalCompositeOperation做混合模式,鸿蒙Canvas也支持,但具体表现有差异。
  • 参数存储 :Web的localStorage到了ArkTS变成了@ohos.data.preferences,从同步变成了异步。

接下来这篇文章,我会用"光绘记"的实际开发经历,带你看看鸿蒙的Canvas怎么模拟长曝光效果------从光点轨迹到光效渲染,再到相机参数预设。


这篇文章聊什么

光绘记这个App,核心要解决两个问题:

  1. 长曝光效果模拟:在Canvas上模拟光绘摄影的长曝光效果
  2. 相机参数预设:提供不同场景下的相机参数参考

对应到HarmonyOS的API,主要涉及:

  • CanvasRenderingContext2D --- 光效绘制
  • @ohos.animator --- 动画控制
  • @ohos.data.preferences --- 参数存储

第一步:光绘数据结构

typescript 复制代码
interface LightPainting {
  id: string;
  projectId: string;
  name: string;
  tool: string;          // 光源工具:手电筒/LED棒/钢丝棉/荧光棒
  technique: string;     // 技法:光写字/旋转光/光纤艺术
  strokes: LightStroke[];
  backgroundColor: string;
  createdAt: number;
}

interface LightStroke {
  points: LightPoint[];
  color: string;
  width: number;
  glowRadius: number;   // 光晕半径
  fadeOut: boolean;      // 是否渐隐
}

interface LightPoint {
  x: number;
  y: number;
  timestamp: number;    // 用于计算渐隐
}

// 光源工具定义
const LIGHT_TOOLS = [
  { id: 'flashlight', name: '手电筒', color: '#FFFFFF', glow: 20 },
  { id: 'led_bar', name: 'LED棒', color: '#FF6B6B', glow: 15 },
  { id: 'steel_wool', name: '钢丝棉', color: '#FFA500', glow: 30 },
  { id: 'glow_stick', name: '荧光棒', color: '#00FF88', glow: 12 },
  { id: 'laser', name: '激光笔', color: '#FF0000', glow: 8 },
  { id: 'phone', name: '手机屏幕', color: '#4A90D9', glow: 25 }
];

第二步:长曝光效果模拟

长曝光的核心视觉效果是:光点移动时留下渐隐的轨迹,形成光线效果。

React版本:

javascript 复制代码
// React版本 - 长曝光效果
function drawLongExposure(ctx, points, color, glowRadius) {
  if (points.length < 2) return;

  points.forEach((point, index) => {
    if (index === 0) return;

    const prev = points[index - 1];
    const opacity = index / points.length; // 越新的点越亮

    // 画光晕
    const gradient = ctx.createRadialGradient(
      point.x, point.y, 0,
      point.x, point.y, glowRadius
    );
    gradient.addColorStop(0, `${color}${Math.floor(opacity * 255).toString(16).padStart(2, '0')}`);
    gradient.addColorStop(1, `${color}00`);

    ctx.fillStyle = gradient;
    ctx.fillRect(
      point.x - glowRadius, point.y - glowRadius,
      glowRadius * 2, glowRadius * 2
    );

    // 画光线
    ctx.strokeStyle = color;
    ctx.lineWidth = 2;
    ctx.globalAlpha = opacity;
    ctx.beginPath();
    ctx.moveTo(prev.x, prev.y);
    ctx.lineTo(point.x, point.y);
    ctx.stroke();
    ctx.globalAlpha = 1;
  });
}

ArkTS版本:

typescript 复制代码
@Entry
@Component
struct LongExposureCanvas {
  @State strokes: LightStroke[] = []
  @State currentStroke: LightPoint[] = []
  @State isDrawing: boolean = false
  @State selectedTool: string = 'flashlight'
  @State isAnimating: boolean = false

  private settings: RenderingContextSettings = new RenderingContextSettings(true)
  private ctx: CanvasRenderingContext2D = new CanvasRenderingContext2D(this.settings)
  private animTimer: number = 0

  aboutToDisappear() {
    if (this.animTimer) {
      clearInterval(this.animTimer)
    }
  }

  // 长曝光绘制核心
  private drawLongExposure() {
    const ctx = this.ctx
    const width = 350
    const height = 350

    // 不清空画布------长曝光是累积效果
    // 只覆盖半透明黑色,模拟"底片"效果
    ctx.fillStyle = 'rgba(0, 0, 0, 0.02)'
    ctx.fillRect(0, 0, width, height)

    // 画所有已保存的笔画
    this.strokes.forEach(stroke => {
      this.drawStroke(stroke)
    })

    // 画当前正在画的笔画
    if (this.currentStroke.length > 1) {
      const tool = LIGHT_TOOLS.find(t => t.id === this.selectedTool)
      this.drawStrokePoints(this.currentStroke, tool?.color || '#FFFFFF', tool?.glow || 15)
    }
  }

  // 画一条笔画
  private drawStroke(stroke: LightStroke) {
    this.drawStrokePoints(stroke.points, stroke.color, stroke.glowRadius)
  }

  // 画光点轨迹
  private drawStrokePoints(points: LightPoint[], color: string, glowRadius: number) {
    const ctx = this.ctx

    for (let i = 1; i < points.length; i++) {
      const prev = points[i - 1]
      const curr = points[i]
      const opacity = i / points.length

      // 画光晕
      const gradient = ctx.createRadialGradient(
        curr.x, curr.y, 0,
        curr.x, curr.y, glowRadius
      )
      gradient.addColorStop(0, color)
      gradient.addColorStop(1, 'rgba(0,0,0,0)')

      ctx.fillStyle = gradient
      ctx.globalAlpha = opacity * 0.8
      ctx.fillRect(
        curr.x - glowRadius, curr.y - glowRadius,
        glowRadius * 2, glowRadius * 2
      )

      // 画光线
      ctx.strokeStyle = color
      ctx.lineWidth = 2
      ctx.globalAlpha = opacity
      ctx.beginPath()
      ctx.moveTo(prev.x, prev.y)
      ctx.lineTo(curr.x, curr.y)
      ctx.stroke()
    }

    ctx.globalAlpha = 1
  }

  // 处理触摸事件
  private handleTouch(event: TouchEvent) {
    const touch = event.touches[0]

    if (event.type === TouchType.Down) {
      this.isDrawing = true
      this.currentStroke = [{ x: touch.x, y: touch.y, timestamp: Date.now() }]
    } else if (event.type === TouchType.Move && this.isDrawing) {
      this.currentStroke.push({ x: touch.x, y: touch.y, timestamp: Date.now() })
      this.drawLongExposure()
    } else if (event.type === TouchType.Up) {
      if (this.currentStroke.length > 1) {
        const tool = LIGHT_TOOLS.find(t => t.id === this.selectedTool)
        this.strokes.push({
          id: `stroke_${Date.now()}`,
          projectId: '',
          name: '',
          tool: this.selectedTool,
          technique: '',
          points: [...this.currentStroke],
          color: tool?.color || '#FFFFFF',
          width: 2,
          glowRadius: tool?.glow || 15,
          fadeOut: true
        })
      }
      this.isDrawing = false
      this.currentStroke = []
    }
  }

  build() {
    Column() {
      Text('光绘创作')
        .fontSize(20)
        .fontWeight(FontWeight.Bold)
        .margin({ bottom: 16 })

      // 光源工具选择
      Flex({ wrap: FlexWrap.Wrap }) {
        ForEach(LIGHT_TOOLS, (tool) => {
          Row() {
            Circle({ width: 12, height: 12 })
              .fill(tool.color)
            Text(tool.name)
              .fontSize(12)
              .margin({ left: 4 })
          }
          .padding(8)
          .borderRadius(8)
          .backgroundColor(this.selectedTool === tool.id ? '#374151' : '#1F2937')
          .onClick(() => { this.selectedTool = tool.id })
        })
      }
      .margin({ bottom: 12 })

      // Canvas画布
      Canvas(this.ctx)
        .width('100%')
        .aspectRatio(1)
        .backgroundColor('#000000')
        .borderRadius(12)
        .onReady(() => {
          this.drawLongExposure()
        })
        .onTouch((event: TouchEvent) => {
          this.handleTouch(event)
        })

      // 操作按钮
      Row() {
        Button('清空')
          .onClick(() => {
            this.strokes = []
            const ctx = this.ctx
            ctx.clearRect(0, 0, 350, 350)
          })
          .backgroundColor('#EF4444')
        Button('撤销')
          .onClick(() => {
            this.strokes.pop()
            this.drawLongExposure()
          })
          .backgroundColor('#374151')
      }
      .width('100%')
      .justifyContent(FlexAlign.SpaceAround)
      .margin({ top: 12 })
    }
    .width('100%')
    .height('100%')
    .padding(16)
    .backgroundColor('#111827')
  }
}

长曝光效果的关键是不清空画布。每次重绘只覆盖一层半透明黑色,这样之前的光点轨迹会慢慢"淡出",模拟真实长曝光的累积效果。


第三步:相机参数预设

光绘记提供6种相机参数预设,从入门到高级:

typescript 复制代码
const CAMERA_PRESETS = [
  {
    id: 'beginner',
    name: '入门',
    description: '适合初次尝试光绘',
    settings: {
      aperture: 'f/8',
      shutterSpeed: '15"',
      iso: 100,
      whiteBalance: '自动',
      focus: '手动对焦'
    }
  },
  {
    id: 'standard',
    name: '标准',
    description: '常用光绘参数',
    settings: {
      aperture: 'f/11',
      shutterSpeed: '30"',
      iso: 100,
      whiteBalance: '钨丝灯',
      focus: '手动对焦'
    }
  },
  {
    id: 'bright',
    name: '强光',
    description: '使用强光源时',
    settings: {
      aperture: 'f/16',
      shutterSpeed: '10"',
      iso: 100,
      whiteBalance: '日光',
      focus: '手动对焦'
    }
  },
  {
    id: 'star_trail',
    name: '星轨',
    description: '结合星轨拍摄',
    settings: {
      aperture: 'f/4',
      shutterSpeed: '30"',
      iso: 800,
      whiteBalance: '自动',
      focus: '无穷远'
    }
  },
  {
    id: 'steel_wool',
    name: '钢丝棉',
    description: '钢丝棉旋转光绘',
    settings: {
      aperture: 'f/8',
      shutterSpeed: '20"',
      iso: 200,
      whiteBalance: '钨丝灯',
      focus: '手动对焦'
    }
  },
  {
    id: 'portrait',
    name: '人像光绘',
    description: '在人像上绘制光效',
    settings: {
      aperture: 'f/5.6',
      shutterSpeed: '8"',
      iso: 200,
      whiteBalance: '闪光灯',
      focus: '手动对焦'
    }
  }
];

第四步:道具清单

光绘摄影需要准备很多道具,光绘记提供了一个可勾选的清单:

typescript 复制代码
const EQUIPMENT_LIST = [
  { id: 'camera', name: '相机', essential: true },
  { id: 'tripod', name: '三脚架', essential: true },
  { id: 'remote', name: '快门线/遥控器', essential: true },
  { id: 'flashlight', name: '手电筒', essential: false },
  { id: 'led_bar', name: 'LED灯棒', essential: false },
  { id: 'glow_stick', name: '荧光棒', essential: false },
  { id: 'steel_wool', name: '钢丝棉', essential: false },
  { id: 'whisk', name: '打蛋器(装钢丝棉)', essential: false },
  { id: 'rope', name: '绳子/链子', essential: false },
  { id: 'colored_gel', name: '彩色滤光片', essential: false },
  { id: 'gloves', name: '防护手套', essential: false },
  { id: 'fire_extinguisher', name: '灭火器', essential: false }
];

总结

这篇文章围绕"光绘记"的长曝光模拟功能,讲解了三个核心主题:

  1. 长曝光效果:Canvas累积绘制 + 半透明覆盖,模拟光点轨迹的渐隐效果
  2. 光效渲染 :用createRadialGradient画光晕,globalAlpha控制透明度
  3. 参数预设:6种场景的相机参数参考,帮助用户快速上手

长曝光模拟的关键是"不清空画布"------每次重绘只覆盖半透明层,让之前的轨迹自然淡出。这个技巧在Web和鸿蒙里都适用。


如果你也是光绘摄影爱好者,希望这篇文章能帮你理解光绘记背后的实现逻辑。去鸿蒙应用市场下载体验一下吧,有问题欢迎交流。

相关推荐
陈_杨1 小时前
鸿蒙APP开发-带你走进节拍器的声音怎么这么准
前端·javascript
搬砖的阿wei1 小时前
Pinia 与 Vuex 区别
前端·vue.js
KaMeidebaby1 小时前
卡梅德生物技术快报|原核表达系统工艺优化:包涵体重折叠 + 分子筛纯化实现功能 RBD 高效制备,附全参数配置
前端·人工智能·算法·数据挖掘·数据分析
最爱睡觉睡觉睡觉1 小时前
代碼案例:CSS 屬性對照
前端·app
VitoChang2 小时前
开发体验超赞的SolidJS2.0来了
前端
CoCo的编程之路2 小时前
2026全栈演进:使用前端开发助手进行项目重构的最佳工具
大数据·前端·人工智能·ai编程·comate
@PHARAOH2 小时前
WHAT - NextAuth 权限认证机制
前端·微服务·服务端
掘金一周2 小时前
问卷调查:如果现在收到裁员通知,你手里的现金流能支撑多久? | 沸点周刊6.4
前端·人工智能·后端
wb043072012 小时前
前厅翻修记——从阿明的“8 秒点餐页“,看前端工程化与用户体验的全面升级
前端·架构·ux