
在深秋的植物手账与古典艺术创作中,有一种诞生于 19 世纪中叶的迷人工艺——日光蓝晒印相法(Cyanotype)。
1842 年,英国天文学家约翰·赫歇尔爵士发明了这种基于铁盐感光的光化学技法;不久后,人类历史上第一位女摄影师兼植物学家安娜·阿特金斯(Anna Atkins),将英国海岸的各种藻类与深秋蕨类植物叶片轻柔平铺在涂有感光剂的纸张上,置于阳光下暴晒,制成了世界上第一本真正的摄影植物标本图册《英国藻类摄影:蓝晒印相印本》。
真实的蓝晒拥有无可替代的自然诗意:
- 在棉浆纸上涂布柠檬酸铁铵与铁氰化钾的混合感光液,初呈浅黄绿色;
- 将带有镂空剪影的植物或负片覆盖其上,在秋日正午的紫外光下暴晒数分钟,感光区域由绿转灰铜、再转青蓝;
- 最终放入清水中冲洗(Washout),未感光的铁盐随水流洗净褪去,留下纸张原本纯净的乳白;而经过光照氧化的铁离子则在纸张纤维深处永久凝结为深邃、温润且永不褪色的普鲁士蓝(Prussian Blue)。
在纯前端数字插画与手账创作中,传统的单色滤镜往往只粗暴地将像素染成统一的单调蓝色,完全丢失了古典蓝晒在曝光过程中的化学色相漂移与水洗过渡。
为了在浏览器中复现这一古老的手工质感,“秋日手账杂货铺”构建了纯前端古典日光蓝晒着色微引擎。系统通过数学公式模拟紫外光累积曝光剂量(UV Exposure Integration),结合普鲁士蓝色谱渐变映射(Prussian Gradient Mapping)与水洗棉浆纸纹理合成,让读者可以在屏幕前体验从暴晒显影到清水冲洗出纯正蓝晒手账标本的全过程。
一、蓝晒反应的光化学与颜色科学建模
要用计算机图形学真实再现蓝晒,必须深度还原其两个核心阶段的光学质感演化:
1. 曝光剂量的连续积分(Exposure Dosage Integration)
在真实曝光中,线稿不同区域的遮光密度(Opacity)决定了紫外光透射率。设 $(x, y)$ 处的线稿遮光度为 $M(x, y) \\in [0, 1]$,紫外光照强度为 $I_{\\text{UV}}$,曝光持续时间为 $T$,则累积化学反应剂量 $D(x, y)$ 可表达为:
$$D(x, y) = 1 – \\exp\\left( – k \\cdot I_{\\text{UV}} \\cdot T \\cdot (1 – M(x, y)) \\right)$$
随着暴晒时间 $T$ 的增加,原本未感光的浅黄绿底色会经历一个独特的**“感光反转(Solarization)”过度**:在达到最终普鲁士蓝之前,高光饱和区会短暂呈现出青铜灰褐色,随后在清水浸润氧化时才彻底爆发为浓郁纯正的深海蓝。
2. 普鲁士蓝的多阶色谱映射(Prussian Blue Gradient Curve)
真正的蓝晒成品绝非单一色阶。在经过清水彻底冲洗后,水彩纸纤维呈现出四个层次分明的色温阶梯:
- 纸基高光白(Paper Base White):植物或墨水完全遮光处,保留未感光的纯棉水彩纸本白(#f7f4ed,温暖柔和的象牙乳白);
- 微感光高调(Cerulean Whisper):边缘透光微弱处,呈现清澈空灵的天青与蔚蓝(#7ea6bf);
- 主调普鲁士蓝(Prussian Core):充分感光区,呈现沉静内敛的中调青靛蓝(#2a4b7c);
- 重曝光暗部(Deep Indigo Abyss):强光照射下的纸纹深陷处,光线在结晶微孔中多次反射,沉淀出近乎黑夜深海般的浓墨重蓝(#14233c)。
二、纯前端架构流程设计
整个微引擎完全基于原生 Canvas 2D 管道与像素级着色器编写,包含三个流水线阶段:
┌─────────────────────────────────────────────────────────────┐
│ 输入源:黑白植物线稿 / 手绘透明轮廓 (PNG / SVG) │
└──────────────────────────────┬──────────────────────────────┘
│
┌──────────────────────────────▼──────────────────────────────┐
│ 曝光计算引擎 (Exposure Shader Pipeline) │
│ – 逐像素提取灰度遮罩 Mask(x, y) │
│ – 结合日光强度与曝光时间,计算非线性化学反应程度 │
└──────────────────────────────┬──────────────────────────────┘
│
┌──────────────────────────────▼──────────────────────────────┐
│ 普鲁士蓝梯度显影与水洗合成 (Prussian Gradient & Washout) │
│ – 颜色查找表 (LUT) 映射:象牙白 -> 天青 -> 普鲁士蓝 -> 幽蓝 │
│ – 叠加热压棉浆水彩纸颗粒(Watercolour Cold-press Grain) │
│ – 冲洗边缘轻微水痕晕染 (Edge Bleed) │
└──────────────────────────────┬──────────────────────────────┘
│
┌──────────────────────────────▼──────────────────────────────┐
│ 产物交付:一键导出古典蓝晒透明植物标本贴纸 │
└─────────────────────────────────────────────────────────────┘
三、纯前端核心工程实战代码
下面是封装完整的古典日光蓝晒着色微引擎代码,零三方库依赖,支持滑块交互实时模拟曝光与冲洗过程:
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>古典日光蓝晒线稿着色微引擎 · 澹锦汐</title>
<style>
:root {
–bg-lab: #14171d;
–card-bg: rgba(26, 31, 40, 0.92);
–cyan-light: #7ea6bf;
–prussian-blue: #2a4b7c;
–text-main: #edf2f7;
}
* { box-sizing: border-box; }
body {
margin: 0;
min-height: 100vh;
display: flex;
background: radial-gradient(circle at center, #1f2735 0%, var(–bg-lab) 100%);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
color: var(–text-main);
overflow-x: hidden;
user-select: none;
}
.darkroom-controls {
width: 330px;
padding: 32px 24px;
background: var(–card-bg);
backdrop-filter: blur(16px);
border-right: 1px solid rgba(255, 255, 255, 0.08);
display: flex;
flex-direction: column;
gap: 24px;
box-shadow: 10px 0 30px rgba(0, 0, 0, 0.5);
}
h1 {
font-size: 15px;
letter-spacing: 2px;
color: var(–cyan-light);
margin: 0;
text-transform: uppercase;
}
.control-item {
display: flex;
flex-direction: column;
gap: 8px;
}
label {
font-size: 12px;
color: #94a3b8;
letter-spacing: 1px;
}
input[type="range"] {
width: 100%;
accent-color: var(–cyan-light);
}
.btn-action {
padding: 12px;
border-radius: 12px;
border: 1px solid rgba(126, 166, 191, 0.3);
background: rgba(126, 166, 191, 0.12);
color: #e2e8f0;
font-size: 13px;
cursor: pointer;
letter-spacing: 1px;
transition: all 0.2s;
}
.btn-action:hover {
background: var(–cyan-light);
color: #0f172a;
box-shadow: 0 4px 15px rgba(126, 166, 191, 0.35);
}
.washout-active {
background: #38bdf8 !important;
color: #0f172a !important;
}
.stage-container {
flex: 1;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
padding: 40px;
position: relative;
}
/* 蓝晒水彩纸装裱台 */
.paper-easel {
position: relative;
width: 480px;
height: 480px;
background: #f7f4ed;
border-radius: 12px;
box-shadow: 0 25px 60px rgba(0, 0, 0, 0.6), inset 0 0 40px rgba(180, 160, 140, 0.3);
overflow: hidden;
display: flex;
justify-content: center;
align-items: center;
}
canvas {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
}
</style>
</head>
<body>
<aside class="darkroom-controls">
<div>
<h1>日光蓝晒工坊 · 澹锦汐</h1>
<div style="font-size: 11px; color: #64748b; margin-top: 4px;">CYANOTYPE ATELIER · 1020</div>
</div>
<!– 紫外暴晒时间 –>
<div class="control-item">
<label>日光暴晒时长 (Exposure Time)</label>
<input type="range" id="exposureRange" min="10" max="100" value="75">
</div>
<!– 棉浆水彩纸纹理感 –>
<div class="control-item">
<label>水彩纸冷压纸纹粗糙度 (Paper Texture)</label>
<input type="range" id="paperGrainRange" min="0" max="30" value="16">
</div>
<!– 清水水洗与氧化还原按钮 –>
<button class="btn-action" id="washoutBtn">
💧 清水冲洗洗净 (Washout & Oxidize)
</button>
<button class="btn-action" id="switchSpecimenBtn">
🌿 切换秋日蕨类 / 银杏标本
</button>
<div style="margin-top: auto;">
<button class="btn-action" id="exportBtn" style="width: 100%; background: var(–cyan-light); color: #0f172a; font-weight: 600;">
导出高保真蓝晒手账贴纸
</button>
</div>
</aside>
<main class="stage-container">
<div class="paper-easel">
<canvas id="cyanotypeCanvas" width="480" height="480"></canvas>
</div>
<div style="margin-top: 16px; font-size: 13px; color: #94a3b8; letter-spacing: 1px;" id="statusNotice">
☀️ 调整曝光时间可观察深浅蓝晒色度;点击“清水冲洗”完成最终氧化显影
</div>
</main>
<script>
const canvas = document.getElementById('cyanotypeCanvas');
const ctx = canvas.getContext('2d');
const exposureRange = document.getElementById('exposureRange');
const paperGrainRange = document.getElementById('paperGrainRange');
const washoutBtn = document.getElementById('washoutBtn');
const switchSpecimenBtn = document.getElementById('switchSpecimenBtn');
const exportBtn = document.getElementById('exportBtn');
const statusNotice = document.getElementById('statusNotice');
// 内存离线遮罩画布 (用于保存黑白植物轮廓线稿)
const maskCanvas = document.createElement('canvas');
maskCanvas.width = 480;
maskCanvas.height = 480;
const maskCtx = maskCanvas.getContext('2d');
let isWashed = true; // 是否已执行清水冲洗
let currentSpecimen = 'fern'; // fern 蕨类 | ginkgo 银杏
// 1. 绘制优雅的秋日植物标本线稿遮罩
function drawSpecimenMask() {
maskCtx.clearRect(0, 0, 480, 480);
maskCtx.fillStyle = '#000000'; // 纯白底
maskCtx.fillRect(0, 0, 480, 480);
maskCtx.fillStyle = '#ffffff'; // 白色代表植物遮光区域
maskCtx.strokeStyle = '#ffffff';
maskCtx.lineCap = 'round';
maskCtx.lineJoin = 'round';
if (currentSpecimen === 'fern') {
// 细腻羽状蕨类叶片 (Fern Leaf)
maskCtx.lineWidth = 3.5;
maskCtx.beginPath();
maskCtx.moveTo(240, 440);
maskCtx.quadraticCurveTo(230, 260, 240, 60);
maskCtx.stroke(); // 主叶梗
// 绘制多级对生羽片
for (let y = 100; y < 400; y += 22) {
const progress = (y – 100) / 300;
const length = (1 – progress * 0.45) * 80;
const curveY = y – (400 – y) * 0.15;
// 左羽片
maskCtx.beginPath();
maskCtx.moveTo(238, y);
maskCtx.quadraticCurveTo(238 – length * 0.6, curveY – 12, 238 – length, curveY);
maskCtx.lineWidth = 2.2;
maskCtx.stroke();
// 右羽片
maskCtx.beginPath();
maskCtx.moveTo(242, y);
maskCtx.quadraticCurveTo(242 + length * 0.6, curveY – 12, 242 + length, curveY);
maskCtx.lineWidth = 2.2;
maskCtx.stroke();
}
} else {
// 重叠双银杏叶
maskCtx.beginPath();
maskCtx.moveTo(240, 430);
maskCtx.quadraticCurveTo(235, 300, 230, 200);
maskCtx.lineWidth = 4;
maskCtx.stroke();
// 扇形叶面
maskCtx.beginPath();
maskCtx.moveTo(230, 200);
maskCtx.bezierCurveTo(130, 180, 90, 80, 150, 40);
maskCtx.bezierCurveTo(190, 10, 225, 40, 230, 70);
maskCtx.bezierCurveTo(235, 40, 270, 10, 310, 40);
maskCtx.bezierCurveTo(370, 80, 330, 180, 230, 200);
maskCtx.fill();
}
// 边角手写标本采集编号与日期
maskCtx.font = 'italic 13px Georgia, serif';
maskCtx.textAlign = 'right';
maskCtx.fillText('Cyanotype Specimen · No. 1020', 440, 455);
}
// 2. 核心:基于光化学剂量的普鲁士蓝色谱像素着色器
function renderCyanotype() {
const w = canvas.width;
const h = canvas.height;
const maskData = maskCtx.getImageData(0, 0, w, h).data;
const outImg = ctx.createImageData(w, h);
const outData = outImg.data;
const exposure = parseFloat(exposureRange.value) / 100;
const grain = parseFloat(paperGrainRange.value);
// 色谱查找常数 (RGB)
// 象牙纸本白: [247, 244, 237]
// 普鲁士蓝中调: [42, 75, 124]
// 普鲁士蓝深调: [20, 35, 60]
// 未冲洗前的黄绿色感光剂: [130, 145, 80]
for (let i = 0; i < maskData.length; i += 4) {
// 植物遮光值 (255 = 完全遮光留白,0 = 完全透光暴晒)
const plantMask = maskData[i] / 255.0;
// 计算有效曝光量 (非线性衰减)
const dosage = Math.min(1.0, (1.0 – plantMask) * exposure * 1.25);
// 模拟水彩纸冷压物理纤维微坑噪点
const noise = (Math.random() – 0.5) * (grain * 1.5);
let r, g, b;
if (isWashed) {
// 【冲洗完成状态】:水洗已带走未感光铁盐,高光呈现纯棉水彩纸本白
if (dosage < 0.08) {
// 完全遮光区:纯白纸纹
r = 247 + noise;
g = 244 + noise;
b = 237 + noise;
} else {
// 普鲁士蓝梯度映射插值
const t = Math.min(1.0, (dosage – 0.08) / 0.92);
// 象牙白向深邃普鲁士蓝插值
r = 247 * (1 – t) + 20 * t + noise * 0.7;
g = 244 * (1 – t) + 35 * t + noise * 0.7;
b = 237 * (1 – t) + 60 * t + noise * 0.7;
}
} else {
// 【未冲洗暴晒中状态】:未感光处呈现暗黄绿,感光处呈现微灰青铜色
if (dosage < 0.1) {
r = 135 + noise;
g = 150 + noise;
b = 85 + noise;
} else {
const t = dosage;
r = 135 * (1 – t) + 60 * t + noise;
g = 150 * (1 – t) + 75 * t + noise;
b = 85 * (1 – t) + 95 * t + noise;
}
}
outData[i] = Math.min(255, Math.max(0, r));
outData[i + 1] = Math.min(255, Math.max(0, g));
outData[i + 2] = Math.min(255, Math.max(0, b));
outData[i + 3] = 255;
}
ctx.putImageData(outImg, 0, 0);
}
// 事件监听
exposureRange.addEventListener('input', () => {
isWashed = false; // 调节暴晒时长时切回显影前
washoutBtn.classList.remove('washout-active');
statusNotice.textContent = '☀️ 紫外光正在感光中… 感光剂呈黄绿暗铜色,请点击“清水冲洗”完成显影';
renderCyanotype();
});
paperGrainRange.addEventListener('input', renderCyanotype);
washoutBtn.addEventListener('click', () => {
isWashed = true;
washoutBtn.classList.add('washout-active');
statusNotice.textContent = '💧 清水冲洗完成!未感光铁盐已洗净,普鲁士蓝彻底氧化定影';
renderCyanotype();
});
switchSpecimenBtn.addEventListener('click', () => {
currentSpecimen = currentSpecimen === 'fern' ? 'ginkgo' : 'fern';
drawSpecimenMask();
renderCyanotype();
});
exportBtn.addEventListener('click', () => {
const a = document.createElement('a');
a.download = `cyanotype-specimen-${Date.now()}.png`;
a.href = canvas.toDataURL('image/png');
a.click();
});
// 首次初始化
drawSpecimenMask();
renderCyanotype();
</script>
</body>
</html>
四、听汐的蓝晒手记:光与时间写下的深海来信
在所有古典摄影与版画技艺中,蓝晒印相法几乎是最具禅意的一种。
它不需要昂贵的光学镜头,也不需要复杂的暗房显影试剂。你只需要采下一片落叶,调好一小勺铁盐,然后将一切毫无保留地交付给正午的太阳与身边的清水。阳光热烈照射的地方,化成了沉静深邃的普鲁士蓝;而落叶静静遮蔽的地方,则固执地守护着棉浆纸最初的洁白。
用几行数学公式模拟出这段光化学反应,绝不是为了追求冰冷的技术炫技,而是希望在匆忙的数字世界里,为每一位热爱手账的朋友留下一枚光的印记。
有些美好需要漫长的等待,就像深秋被阳光晒透的叶片,在清水浸过的那一刻,才会迸发出最迷人的深蓝。




