光绘记的长曝光模拟: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,核心要解决两个问题:
- 长曝光效果模拟:在Canvas上模拟光绘摄影的长曝光效果
- 相机参数预设:提供不同场景下的相机参数参考
对应到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 }
];
总结
这篇文章围绕"光绘记"的长曝光模拟功能,讲解了三个核心主题:
- 长曝光效果:Canvas累积绘制 + 半透明覆盖,模拟光点轨迹的渐隐效果
- 光效渲染 :用
createRadialGradient画光晕,globalAlpha控制透明度 - 参数预设:6种场景的相机参数参考,帮助用户快速上手
长曝光模拟的关键是"不清空画布"------每次重绘只覆盖半透明层,让之前的轨迹自然淡出。这个技巧在Web和鸿蒙里都适用。
如果你也是光绘摄影爱好者,希望这篇文章能帮你理解光绘记背后的实现逻辑。去鸿蒙应用市场下载体验一下吧,有问题欢迎交流。