普通视图

发现新文章,点击刷新页面。
昨天以前dtysky|一个行者的轨迹

回顾2022

作者 dtysky
2023年1月21日 08:00

过去,既已确定,则悔恨即为罪恶。
未来,既已注定,则恐惧即为罪恶。
现在,既已决定,则怠惰即为罪恶。

今年的回顾我改变了一贯的文章形式,将媒介换成了视频视频,做了一次尝试,按照惯例分为“回顾”和“正文”两部分。

第一部分:

""

第二部分:

""

我的简历

作者 dtysky
2022年12月28日 08:00

H光

邮箱 : [email protected]

Github : dtysky

Bilibili : 瞬光寂暗


学历和工作

2020.08 - 现在,微信,小程序基础,图形/前端搬砖
2019.07 - 2020.07,支付宝,前端技术专家
2018.03 - 2019.07,支付宝,高级前端开发工程师
2016.08 - 2018.03,哔哩哔哩,前端工程师
2015.10 - 2016.07,上海禾赛光电,软件工程师
2015.07 - 2015.09,华为,逻辑(FPGA)工程师
2014.10 - 2015.06,Xilinx,研发实习生
2011.08 - 2015.06,东南大学,测控技术与仪器,工学士

发展方向

游戏开发和WEB开发。

技能

工具

工作语言:TypeScript, C#, C++, CSS...需要什么就写什么呗,哪有那么多讲究。
领域技术:WebGL,WebGPU,FPGA,游戏开发...需要什么去学呗,哪有这么多讲究。
业余语言:Rust, Python, Scheme, VHDL, Verilog...更没讲究了,写着玩。

专业领域

工作:WEB开发, 游戏引擎开发, 图形学
业余:数字图像处理, FPGA, 文学/影像创作

个人介绍

生命是积累痛苦的巡礼,但这断然不是虚无与毁灭的故事,在前方一定还存在着救赎和希望。

非典型码农,INFP,ADHD,宿命论者,混乱善良。自洽,坦然,从容。
厌恶无聊,追求有趣,言出必遂,尽力而为。但为了避免过早毁灭,也知道节制。
最欣赏的特质是清醒和勇气,最讨厌的特质是怯懦和贪婪。
看过不少书,经历过不少事,但仍旧保持着初心。
从小漂泊过半个中国,没有故乡。
毕业后换过四个城市五家公司,在B站用爱发电过,在阿里经历过斗争,现在在鹅厂搬砖,工作内容和兴趣一致。
事业比大多同龄人快,见识过很多光鲜外表下的不堪,看透了许多事,觉得任何交往中最重要的是真诚。
人格已达「完全之形」,拥有不随环境变化的强硬核心,承受逆境的能力很强。
对世俗物质本质上没有需求,但仍然在不影响创作的前提下努力工作赚钱,为了获得更多的体验作为输入。
严格来说我有很多种世俗上更好的选择,不过都放弃了。
因为从个人审美来看,现在选择的这种生存方式可能会支付更多代价,但却是最美丽动人的。

欣赏过一些严肃文学,文艺片,舞台剧,摇滚,独立游戏。
持续探索适合自己的创作方式,想创作出令自己满意的作品,并产生社会价值,让他人得到慰藉或清醒。
写过剧本小说,系统学过画画,尝试过唱歌,学摄影中。每周末都会出去拍VLOG,假期会出去旅游。
因社恐长期探索自拍方式,目前借助A7M4和后期调色加持,渐入佳境。

年少时蔑视命运,坚信“我命由我不由天”、“人定胜天”,做事没有节制和分寸,以对自己和周围的损害换取高人一等,现在想来只是不知道恐惧、也不知伤害为何物。
再后来总是被命运戏弄,了解到了人的局限性,知道了人不一定胜天,就多了些怯懦和贪婪,想苟活于世,尽可能向这个世界索取更多,达到一种“盈余”性质的无憾。
而现在终于理解了命运的本质:“命”为宿命,在人生的起点便已经达成了某种意义上的结束,是无法改变的一个范围;而“运”则为运势,是可以尽力去改变和拨弄的,而这种“拨弄”,就是在既定结局前的选择。
向世界索取一些,然后再向世界归还等价的一些。选择与代价的一体两面,成长所意味的背负,以负担换取生命的厚度。知晓恐惧后却仍要向前走,观测之后更重要的是付诸行动。
以前不理解为什么异域镇魂曲的主角在历经一切、得知真相的最后,选择放弃永生随意捡起一把武器冲向战场,现在我终于彻底理解了——

“意义并不在于‘包罗万象’的观测,而存在于任一行动的回响之中。”

这大概就是所谓“命运”在审美层次的真正内涵吧。

""


主要项目(工作I)

XR-FRAME

说明: 在微信小程序中,实现一套官方的混合渲染、高性能、易用、强扩展性、渐进式、遵循小程序开发标准的XR渲染方案。
规模: 大
性质: 公司项目
职位: 主要负责人,整体架构设计、渲染系统、资源系统、XR系统。
进度: 第一版已发布,第二版开发中
使用技能:
图形学, 前端架构, 游戏引擎架构, XR, Typescript, C++......
详见官网:微信小程序 XR-FRAME
惯例彩蛋:小程序示例官方AR彩蛋,开启传送门探寻世界真相
完成内容:

  1. 负责了整体架构,在顶层实现了一个小程序的渲染后端,提供了给用户高度的组件定制、资源定制能力。
  2. 基于跨平台的渲染层(微信内是客户端实现),负责了主体渲染前端和WebGL后端的实现。
  3. 其他重要系统,例如资源系统、AR系统、后处理系统等等的实现。
  4. 富有个人特色的文档,全面的数据上报监控。
  5. 和多个团队以及外部服务商对接合作,进行业务落地的推进。
  6. 第二版将实现3D和2D标签的混合,在GL中统一渲染,便于开发者实现更加酷炫的UI。

微信小游戏高性能解决方案

说明: 在微信小游戏环境内提供逼近原生的性能。
规模: 超大
性质: 公司项目
职位: 主要负责渲染部分。
进度: 全新版本已发布
使用技能:
图形学, 游戏引擎架构, Typescript, C++......
详见官网:微信小游戏框架
完成内容:

  1. 参与渲染引擎设计,与同事一同决定了渲染部分的方案。
  2. 负责了整个渲染引擎前端部分的实现。
  3. 负责了整个引擎的Web跨端方案实现。

项目已经支持不少项目,比如新轩辕传奇天龙八部荣耀版,可在微信小程序内直接搜索游玩。

支付宝2020新春五福活动

说明: 负责首页3D展示福满全球亿级前端项目中的3D技术-支付宝2020年新春活动的背后
规模: 大
性质: 公司项目
职位: 项目前端负责人。
进度: 已上线
使用技能:SEIN.JS
完成内容:

  1. 主会场3D模型,5个场景,极致优化,只占20M内存。
  2. 福满全球,几乎所有元素都在3D场景内,所有材质均为定制,极致优化了内存。
  3. 可靠的降级方案。
  4. 覆盖面极广(10亿用户级),极低的闪退率。

SEIN.JS

说明: Web3D游戏引擎。
规模: 超大
性质: 公司项目
职位: 项目负责人,负责内核开发,体系设计,跨BU合作项目管理,部分工具开发
进度: 已开源,持续迭代中
使用技能:
图形学, 游戏引擎架构, Typescript, Unity, Unreal4, C#, Webpack, WebAudio......
项目源:一系列项目,比如Sein.jsSeinJSUnityToolkitseinjs-gltf-loader等等...
惯例彩蛋:荒诞,艺术,存在
完成内容:

  1. 游戏引擎本体
  2. 空间音频系统、相机控制器、GUI等扩展系统(合作)和组件。
  3. Unity扩展
  4. CLI
  5. 一系列Webpack扩展Loader以及Plugin
  6. VSCode扩展
  7. Inspector

项目已经支持内部不少业务。

Paradise平台

说明: 支付宝内部互动图形探索平台。
规模: 大
性质: 公司项目
职位: 项目前后端负责人
进度: 完成
使用技能:
Typescript,NodeJS,React, Mysql......
项目源:闭源
完成内容:

前端以及服务端,拥有各种复杂的逻辑,包括但不限于创建作品、自动创建仓库、绑定HOOK自动构建、审核、测试、CLI、Lottie和3D模型预览发布等等。

Bilibili圣诞音游《Jingle Beats》

说明: Bilibili圣诞音游《Jingle Beats》
规模: 大
性质: 公司项目
职位: 项目前端负责人、开发者、原案
进度: 完成
使用技能:
PIXI.js, Typescript, WebGL, Tween,MUG、Touch Events......
项目源:Bilibili圣诞游戏剖析-用pixi.js实现鬼畜音游
完成内容:

一个HTML5的偏硬核音游,基于2D渲染引擎PIXI.js。

Bilibili七夕游戏《Double;7》

说明: Bilibili七夕游戏《Double;7》
规模: 大
性质: 公司项目
职位: 项目前端负责人、开发者、原案、制作进行
进度: 完成
使用技能:
Egret, Typescript, WebGL, Tween......
项目源:egret-galgame
Bilibili《七夕之约 - Double;7》技术剖析
完成内容:

一个HTML5的Galgame,基于游戏引擎Egret,使用了陀螺仪、Websocket等技术,用技术为阿宅带来人文关怀。

BML2017主视觉

说明: BML2017主视觉
规模: 大
性质: 公司项目
职位: 项目前端负责人和开发者
进度: 完成
使用技能:
Node.js, React.js, Typescript, Less, Webpack......
项目源:BML2017主视觉技术剖析
完成内容:

一个酷炫的主视觉,用到大量DOM动画和H5视频,PC和移动端双端适配,包含一个复杂的贴吧性质的讨论区。

Bilibili活动管理后台

说明: 一个超大型复杂管理后台。
规模: 超大
性质: 公司项目
职位: 核心开发者和架构者之一
进度: 完成
使用技能:
Node.js, React.js, Redux, Express.js, ES6, Scss, Webpack......
项目源:闭源。
完成内容:

大型活动管理后台,包括一个复杂CMS,数据源聚合管理,权限系统和发布系统。
全栈开发,前端技术栈为React + Redux + React-router + Immutable,后端技术栈为Node + Express。

业余项目

Project Tomorrow

说明: 独立游戏。
规模: 大
性质: 梦想
职位: 负责人,剧本,程序,演出,出钱
进度: 剧本创作
使用技能:写作,游戏开发,花钱
完成内容:

第二版剧本已完成,MVP版本创作中。

Awaken

说明: 基于Hybrid方案和WebDAV的全平台开源阅读软件。
规模: 中
性质: 产品输出
职位: 独立完成
项目源:
Awaken
使用技能:Hybrid,WebDAV,Android,iOS,Tauri,React
完成内容:

功能介绍:Awaken-开源跨平台多端同步阅读软件
技术教程:Awaken-基于Hybrid方案和WebDAV的全平台开源阅读软件

Project Love

说明: 29岁前交友企划,以及某种意义上的人生回顾。
规模: 中
性质: 记录
职位: 创作
进度: 完成
使用技能:写作,剪辑
完成内容:

公众号发文:Project Love
VLOG:某「非刻板印象INFP码农」的日常一天

Project Self

说明: 28岁自我记录企划。
规模: 中
性质: 记录
职位: 需求定制,创作,演讲
进度: 完成
使用技能:写作,花钱
完成内容:

写真集+文章:Project Self
个人访谈:访谈公众号发文

WebGPU Renderer & Path Tracer

说明: 一个基于WebGPU的渲染引擎,以及基于其实现的路径追踪渲染器,并产出了系列教程。
规模: 中
性质: 技术学习
职位: 独立完成
进度: 完成
项目源:
webgpu-renderer
Live Demo
使用技能: WebGPU, Typescript, 图形学
完成内容:

使用WebGPU实现了一个渲染器,包含绝大部分3D渲染功能。
利用渲染器,使用光栅化+计算着色器混合方式,实现了一个路径追踪渲染器,其中包括场景合并、BVH构建、射线求交、蒙特卡洛方法、BRDF、BSDF、降噪等。
产出了多篇系列教程:WebGPU实时光追美少女系列

Double;14

说明: 庆生用原创游戏。
规模: 中
性质: 生日纪念
职位: 剧本,程序
进度: 已完成
使用技能:
写作,编剧,Unity
项目源: 闭源
完成内容:

游戏录屏: 《Double;14》个人独立游戏 小说版本: Double;14

gl-matrix-wasm

说明: 将gl-matrix移植到WebAssembly。
规模: 中
性质: 技术输出
职位: 独立完成
进度: 完成
项目源:
gl-matrix-wasm
Live Demo
使用技能: Rust,WebAssembly
完成内容:

使用Rust + wasm-bindgen + wasm-pack技术栈。
提供分离、单JS两种形式。
包含完全的单元测试,以及完整的Benchmark。

D2-2017前端论坛嘉宾

说明: 代表B站,在D2-2017前端论坛进行了分享,中心主题和PPT在这里:D2-现代前端-对视觉和交互的探索
规模: 中小
性质: 分享
职位: 独立完成
进度: 完成
使用技能: PPT,演讲......

kanata

说明: 一个纯前端实现、无依赖的图像处理库,纯TypeScript编写,并在计划WebAssembly的版本。
规模: 中
性质: 技术输出
职位: 独立完成
进度: 开发中
项目源:
kanata
Live Demo
使用技能: TypeScript,React.js,数字图像处理......
完成内容:

主要包含一个ImageCore和若干点操作、几何变换、直方图操作和局部滤波器。
本身支持单操作引用,设计上支持流式处理,为敏感的性能做了一些优化,优于市面上很多库。
目前已然实现了许多操作并搭好了测试框架、写了一部分测试,剩下的操作正在实现中。

hana-ui

说明:和两位队友一起实现的一个React UIKit,作为本项目的负责人,我设计项目框架,完成了项目开发、文档和DEMO的整体结构。除了设计,我也承担了相当的组件开发,在这个过程中我对React、工程化和样式的理解都大有进步。
规模: 中
性质: 技术输出
职位: 独立完成
进度: 已完成并在内部系统可用、部分外部系统可用,开源准备中。
项目源:
hana-ui
Homepage
惯例彩蛋:hana song
使用技能: React.js, ES6, Scss......

BlogReworkPro

说明: 重构BlogRework,这是此Blog迎来的第四次重构了,和上一次的间隔比预期要早一些,不过这种事早点没啥坏处。这次重构主要是重写了前端、修了一些后端的BUG,跟进ES6,用Eslint和Flow约束代码规范,上了React最佳实践全家桶并且实现了完美的服务端渲染,加了Memory Cache,样式换成了less,DOM语义化也做了,构建工具也换成了gulp,也就是说,上一次遗留的Feature基本都搞定了。
规模: 中小
性质: 新技术学习实践
职位: 独立完成
进度: 完成
使用技能:
Python, Node.js, Flask, React.js, Redux, MongoDB, Express.js......
项目源:
BlogRewrokPro
【React/Flask】BlogReworkPro-Rework the BlogRework
【React/Redux/Router/Immutable】React最佳实践的正确食用姿势
【React/Redux】深入理解React服务端渲染
【Flask/React】此博客服务端的缓存实现
【Less】实现可选参数以及各种autoprefixer
完成内容:

  1. 服务端渲染。
  2. 内存缓存。
  3. 资源优化。
  4. SEO。

MoeNotes

说明: 正在使用Redux + RXJS重构。MoeNotes是一个简单的日记写作软件(当然,我也用其写一些不是特别复杂的小说和文章),不同于印象笔记、Onenote、Leanote等,它所要解决的问题仅仅是“本地展示”,也就是在本地管理你的日志文件,并提供一个类似于Onenote的分类体验。本软件使用Markdown作为笔记编写语言,每一篇日记都会以.md文件的形式保存到本地而不是数据库中,用户可以自行选择如何同步这些文章以及将它们同步到多少地方,完全不依赖于平台,这也是我编写本软件而不使用现成日记软件的一个主要的原因。当然,对文章和分类进行拖动排序也是被支持的。除此之外,本软件还支持“即写即看”,“专注写作”和“专注阅读”三种模式,也可以进行主题的切换和自定义,由于是基于web进行得开发,所以扩展起来也十分方便和简单。
规模: 中
性质: 造一个满足自己需求的轮子
职位: 独立完成
进度: 完成
使用技能:
Javacript, Node.js, React.js, Electron, HTML, CSS, Grunt, Webpack, Jasmine......
项目源:
MoteNotes
Github
完成内容:

  1. 本地文件树管理。
  2. OneNote的分类体验,增强版Markdown支持。
  3. 即使预览、专注写作和专注阅读模式。
  4. 可定制主题。

BlogRework

说明: 重构Blog为SPA。
规模: 小
性质: 新技术学习实践
职位: 独立完成
进度: 完成
使用技能:
Python, Node.js, Flask, React.js, MongoDB, Express.js, Jade, CSS......
项目源:
[Flask/React/MongoDB]BlogReworkIII-如何搭建一个动态Blog
BlogRewrok
完成内容:

  1. 扩展Markdown文章解析以及数据库管理。
  2. 后端Web服务器。
  3. 前端界面。
  4. 前端WEB服务器。
  5. SEO。

主要项目(工作II)

激光雷达上位机

说明: 用于展示激光雷达的3D数据,包括实时显示、数据录制、数据回放。
职位: 负责人
进度: 基本功能完成
使用技能:
JavaScript, C++, Python, Electron, Node.js, React, Three.js, HTML, CSS, Webpack, Grunt 项目源: 闭源
完成内容:

  1. 整体功能分析,实现了UI的设计和所有功能的开发。
  2. 用Electron作为框架,使用web的方式开发了桌面应用。
  3. 编写了C++的Node.js扩展,提高了点云坐标系转换效率。
  4. 用Three.js完成了点云的绘制。
  5. 实现了数据、显示、控制的隔离,自己实现了事件驱动的设计,保证可维护性。

无人机甲烷监控数据可视化平台

说明: 将后台数据库进行表格形式的显示,兼容WEB、iOS和安卓三个平台。
职位: 负责人
进度: 基本完成
使用技能:
JavaScript, Python, Flask, MySQL, HTML, CSS, PyJade, Node.js, React native, Object-C
项目源: 闭源
完成内容:

  1. 开发基于Flask的后端以及使用PyJade模板进行网页显示
  2. 开发了一版Object-C编写的APP,用于熟练iOS底层。
  3. 用React native开发了iOS和安卓的APP。
  4. 设计了美观的UI。

PM25传感器数据监测平台

说明: 接手项目,开发一个数据监测平台,收集不同地方的传感器传回的数据,进行记录和分析
职位: 负责人之一
进度: 正在进行
使用技能:
JavaScript,Node, React, MongoDB, Grunt, Python, HTML, CSS
平台:
OSX(开发),Ubuntu(部署)
项目源: 闭源
完成内容:

  1. 开发基于Node.js的后端
  2. 开发基于React.js的前端,用Grunt管理。
  3. 包含数据库管理,数据下载,数据呈现等功能。
  4. 做了设计,美化了UI。

无人机定位

说明: 寻找并实现无人机的一些定位方式
职位: 负责人
进度: 基本完成
使用技能:
Python
平台:
Windows(PC),Ubuntu(Ordroid)
项目源: 闭源
完成内容:

  1. 差分GPS的研究和应用。
  2. 激光定位的研究和应用。
  3. UWB定位的研究和应用。

主要项目(大学)

梦见星空之诗 - Aria der Freiheit und des Seins:

说明: 原创游戏。
规模: 超大
性质: 梦想
职位: 企划,监督,设定,剧本,程序
进度: 长期规划,剧本重构中
使用技能(已经):
哲学(半吊子),设定,写作,编剧,Python,Ren'py
使用技能(预定):
Unity3d,编译器设计,FPGA设计,商学
项目源: 闭源
完成内容:

  1. 游戏剧本解析器。
  2. 70万字剧本原稿。
  3. 游戏系统和界面(暂定)。

体三维显示器:

说明: 一个分辨率较高的、由二维LED点阵旋转的三维显示器。
规模: 大
性质: 省级SRTP
职位: 独立完成
进度: 基本完成
使用技能:
PCB,FPGA,C,C#,Autocad
项目源:
控制PCB机械
完成内容:

  1. 学习Cadence套件,设计了一个由0603LED,PMOS,NMOS构成的120*114,密度为15.2d、cm^2的LED阵列;同时,完成了由触发器、锁存器、电源部分构成的此LED阵列的控制板,控制板为四层,走线密度较高。
  2. 学习使用FPGA,利用Altera的CycloneIV系列的器件,使用VHDL语言完成了:
    1).DDR2控制器,完全由自己实现,并自己编写Testbench,利用Modelsim进行了批量数据验证,但缺少PHY的支持。
    2).LED控制器,完全由自己实现,使用ROM进行了板上验证。
    3).学习使用高速USB芯片cy68013,熟悉其slave-fifo模式,完成了它的FPGA控制部分,进行了板上验证。
  3. 学习使用C#语言,使用cy68013芯片的提供的. net下的API,完成了其上位机。
  4. 学习C语言,完成了cy68013的固件部分设计。
  5. 机械部分的设计,设计了底座、联轴器,以及LED阵列的装载板;使用Autocad设计。
  6. 基于Matlab的三维切片。

游戏剧本解析器:

说明: 一个分离剧本和代码的解析器,用于将自己设计的DSL解析成Ren'py的脚本,提供扩展和输入插件。
规模: 中
性质: 个人项目
职位: 独立完成
进度: 基本完成
使用技能: Python,Ren'py
项目源: Gal2Renpy
完成内容:

  1. 学习使用游戏引擎ren’py和Python,了解了各自特性。
  2. DSL的形式继承自xml语言,自己完成了标记语言的解析,以及与ren’py脚本的映射,同时提供给用户自行扩展的接口,具有良好的可扩展性。
  3. Sublime的插件,方便输入。

FPGA Image Library:

说明: 一个开源的FPGA图像处理库,将一些图像处理操作在FPGA上实现,每一个操作都会封装成一个模块,并拥有各自的软件测试与功能仿真,测试图片可以自由选取,并且设计为统一接口,流水化处理。
规模: 大
性质: 独立项目
职位: 独立完成
进度: 1.0版本发布,由于工作原因暂时中断= =
使用技能: FPGA设计(Verilog, SystemVerilog),Python,图像处理
项目源: FPGA-Imaging-Library
完成内容:

  1. 灰度化,二值化,亮度和对比度变换。
  2. 彩色字符输出(任意字体)(暂停)。
  3. 帧控制器,行缓存生成器,窗口生成器。
  4. 窗口均值滤波,二值腐蚀膨胀,二值模板匹配,灰度腐蚀膨胀,排序滤波器。
  5. Harris角点检测。
  6. 平移,缩放,镜像,裁剪,旋转,仿射变换。
  7. 预计加入直方图操作。

我的主页:

说明: 一个用于记录自己学习经验的博客。
规模: 小
性质: 个人项目
职位: 独立完成
进度: 完成
使用技能 HTML,CSS,JS
项目源: 不开源 完成内容:

  1. 学习使用pelican(基于jinjia2),学习css、html、js,在VPS上搭建。

一个面向教学的单周期MIPS CPU:

说明: 一个MIPS CPU,单周期32bits,vivado搭建,模块化设计,设立了tcl文件简化预览。
规模: 小
性质: 实习项目
职位: 独立完成
进度: 完成
使用技能:FPGA设计, Verilog, Systemverilog, Python
项目源: SIMPLE_MIPS_CPU
完成内容:

  1. 完成了ALU, REGFILE, CONTRLO_UNIT, DATAPATH, INST_MEM, DATA_MEM模块的设计与随机测试,并将它们拼接,完成了用于功能仿真的CPU。
  2. 完成了KEY2INST, SHOW_ON_LED模块的设计与测试,将它们拼接于以上模块中,设计了一台按键编码,用于CPU的板上测试。
  3. tcl文件的建立,用户可以在vivado界面写source tcl文件来快速建立工程。

其他

欲戴皇冠,必承其重。

一个人不可能被所有人理解,这几年遭受的攻击、嘲讽、中伤即从中来。

人要为其言论负责,表达的代价也即为此。

如此坚持的结果应有两种可能——倘若无悔,那将是无上的荣耀;倘若后悔,也将是深刻的戏谑。

但无论如何,这种种经历都会成为我一生的注脚。

当拥有了永恒的视角后,你可以选择入戏当演员去起舞,也可以选择出离来当个观众,但只有一点:

作为演员的时候,我们不可忘却愤怒。
作为观众的时候,我们不可忘却叹息。

我将倾尽所能去体验、去观测、去记录、去表达。
不能迷惘、不许后悔、不准遗憾、不计代价、不可回头。
直至肉身灭亡的一刻到来。

Awaken-基于Hybrid方案和WebDAV的全平台开源阅读软件

作者 dtysky
2022年12月28日 07:33

在本篇文章中,我将从技术选型开始,分享我在开发这个阅读软件过程中的一些经验和心得,也算是对个人技术道路的又一个路标。

前言

今年六月,亚马逊宣布Kindle将在次年退出中国市场,听到这个新闻的我开始寻找它的替代品,同时由于在思考后彻底失去了对内容平台的信任,我对这个替代品定了以下要求:

  1. 存储不依赖于平台。
  2. 能够跨平台和设备共享,要求支持桌面/安卓/iOS全端。
  3. 允许笔记和进度同步。
  4. 笔记协议开放,能够从Kindle导入。

带着这些要求寻觅了许久后,我并未找到完全满足的软件。作为一个合格的程序员,我便自然有了自己去实现它的想法,这同时也可以作为我的最终项目《Project Journey》预热。但由于事情太多同时离Kindle完全停止运营为时尚早,我并未立即开始这个项目,而再次启动它的契机,则是九月份的另一个项目。

在延续创作完去年的独立游戏《Project Tomorrow》的第二版剧本后,我陷入了美术的困境,为了避免一开始就把事情搞砸,我启动了一个另一个比较小的博客改版项目《Project Totem》来磨合和美术的相性,但很遗憾最终失败了(题外话也因此我打消了相当的找个美术对象、或者说合作伙伴对象的想法)。但无论现状如何,项目总要继续推进,于是我最终决定提高自己的艺术修养,让自己继程序、剧本后也成为美术总监把控全局,便于之后找外包。而这个设想前提则离不开读书,所以正好借这个机会完成这个读书软件。

我知道以上思维路径看起来很离谱,但我确实是这么想的。

从九月底开始,我便启动了这个命名为《Awaken》的项目,花了近两个月的业余时间,我终于将其完成。

项目开源在此:

https://github.com/dtysky/Awaken

功能介绍在此:

Awaken-开源跨平台多端同步阅读软件

技术选型

经过这么多年的开发,我逐渐明白了技术选型的重要性。看起来轻松的方案最后可能会踩更多的坑,而看起来麻烦的方案可能却是弯路最少的,反而会达到稳定和效率的平衡。技术选型的前提是需求,而由前言中提到的需求可得最影响决策的两个需求是——“同步”和“全平台”。

“同步”决定了C/S架构,继而需要考虑服务端存储和多端同步方案;而“全平台”则决定了客户端需要一种跨端方案。

服务端

一般来讲,服务端有两种选择:

  1. 像常见平台那样,服务端除了存储还接管同步逻辑,书库一开始就存在于服务端,用户只是去获取并将其拉取到客户端,而笔记、进度等也都由服务端处理同步后下发。
  2. 服务端只做存储,所有的同步逻辑交由客户端,书籍由客户端添加上传,笔记、进度也都在客户端运算。

第一种方案的优势是逻辑中心化,清晰简单,要处理的状况少,但代价就是需要一个独立的服务端,而且书库的维护可能要另外写逻辑。比较适合大平台,但不适合个人维护,尤其在国内这种动辄要备案的环境更是巨麻烦。

第二种方案的优势就是只需要一个远端存储方案,而代价就是逻辑是分布式的,状况比较多且复杂。这种方案也比较适合个人用户。

综合权衡后我选择了第二种方案,那么接下来的问题只有一个了——如何选择存储方案。这个没什么说的,我选了WebDAV

WebDAV (Web-based Distributed Authoring and Versioning) 一种基于 HTTP 1.1协议的通信协议。它扩展了HTTP 1.1,在GET、POST、HEAD等几个HTTP标准方法以外添加了一些新的方法,使应用程序可对Web Server直接读写,并支持写文件锁定(Locking)及解锁(Unlock),还可以支持文件的版本控制。

目前国外支持WebDAV的网盘有不少,国内的话就坚果云吧,我也一直用的这个。

客户端

客户端的选型要比服务端复杂一些,不同于服务端只负责存储,客户端要负责整个软件所有的逻辑。倘若只是单个平台还没啥,但一旦涉及到跨端就麻烦了,主要考虑最终效果和开发成本两点:

  1. 不同平台分别实现,优点是最终性能肯定好上限高,代价是承受不起的开发成本。
  2. Flutter,移动端效果应该不错,桌面端残废,开发效率中等吧。
  3. ReactNative,emmm...不想再踩一次坑,桌面端也差不多残废。
  4. Hybrid分层方案,在Webview跑的JS前端 + 客户端通过JSB/XHR拦截实现的后端,优点是能充分利用JS生态,代价是效果受到Webview约束,仍然要实现不同客户端的JSB拦截。

其实不用再多分析大家也能看出来了,综合效果和开发成本,只有第四种方案是可行的:Hybrid本身就是一种业界早已成熟的方案,没有太多坑,不会出现原理上难以解决的工程问题,而且能充分利用JS生态也省去了很多功夫,我是没兴趣为了这么个次要项目重新造一些轮子。

开发流程

在具体的实现前,需要先确定整个开发测试和构建的流程。

""

如图,这里我按照一个过时前端老人的习惯,选择了typescript作为主要开发语言,webpack作为构建工具,使用dev-server做为开发服务器,发布时稍微改动做下打包就行。同时为了在开发阶段测试webdav协议,我写了webdav.server.js在本地开了个服务。

代码本体在src下,interfaces中包括后端接口协议和书籍同步接口协议的定义,backend中是不同平台下后端的具体实现,frontend最后则是具体的前端逻辑。

platforms是不同平台下的项目工程。在开发阶段,我用dev-server开个支持hot-load的本地服务器,以不同平台的Webview打开本地Url来方便调试;在发布阶段,我将产物构建到三个平台工程的指定目录下,再以后续会提到的手段加载。

test中是提供测试的一些书籍和Kindle导出的笔记。

后端实现

客户端的后端部分主要负责通过一致的接口协议,将Native基础能力暴露给Webview前端使用。

在代码接口上,我在src/interfaces/IWorker中定义了接口协议,并在src/backend中在各端具体实现,同时在``

接口协议

分层设计最重要的是接口协议,我这里依照项目需求,设计了以下接口:

export type TBaseDir = 'Books' | 'Settings' | 'None';
export type TToastType = 'info' | 'warning' | 'error';

export interface IFileSystem {
  readFile(filePath: string, encoding: 'utf8' | 'binary', baseDir: TBaseDir): Promise<string | ArrayBuffer>;
  writeFile(filePath: string, content: string | ArrayBuffer, baseDir: TBaseDir): Promise<void>;
  removeFile(filePath: string, baseDir: TBaseDir): Promise<void>;
  readDir(dirPath: string, baseDir: TBaseDir): Promise<{path: string, isDir: boolean}[]>;
  createDir(dirPath: string, baseDir: TBaseDir): Promise<void>;
  removeDir(dirPath: string, baseDir: TBaseDir): Promise<void>;
  exists(filePath: string, baseDir: TBaseDir): Promise<boolean>;
}

export interface IWorker {
  fs: IFileSystem;
  loadSettings(): Promise<ISystemSettings>;
  saveSettings(settings: ISystemSettings): Promise<void>;
  selectFolder(): Promise<string>;
  selectBook(): Promise<string[]>;
  selectNote(): Promise<string[]>;
  showMessage(msg: string, type: TToastType, title?: string): Promise<void>;
  setBackground(r: number, g: number, b: number): Promise<void>;
  onAppHide(callback: () => void): void;
  getCoverUrl(book: IBook): Promise<string>;
}

接口的方法名都很明显了,不做过多解释。接下来要做的就是在各端实现这个IWorker接口。

桌面端

在桌面端,基于浏览器的方案有不少,比如最广为人知的Electron,还有类似的CEF等,其基本都是打包了一个Chromium进去,在开发简单、一致性强、兼容性强等优点下,也有安装包大小和内存开销等为人诟病的代价。

一开始我是准备直接用Electron的,但由于其只支持桌面,想偷懒的我便不由自主得想:“都2022年了,这么热衷造轮子的前端业界,不会还没有能直接跨所有端的方案吧?”虽然答案仍然确实是没有,但却意外发现了一个框架——Tauri

Tauri是基于RustWebview的混合应用开发框架,其目前支持全桌面平台,并计划支持客户端(当然遥遥无期)。其优势是利用系统原生的Webview(不错桌面系统也有Webview),包体积很小并且内存开销会小一些,但相对代价就是很难利用Node生态,并且可能存在平台一致性问题,在某些低版本操作系统不支持。

经过权衡,最终我选择了Tauri,因为这个应用并不需要什么扩展逻辑,只需要基本的文件系统、提示、桌面选择器等等基本能力,而这些它都有官方支持。接下来,就让我们看看怎么在桌面端实现这个接口。

FileSystem

首先是接口中的文件系统,看到方法名便可以知道它们本质上就是对本地文件的存取。这个在Tauri中很简单,其官方提供的库@tauri-apps/api中就有相关能力,只需要将其引入并在tauri.conf.json中的tauri.allowlist中配置好fs的参数即可使用,比如:

import {fs} from '@tauri-apps/api';

async readFile(filePath: string, encoding: 'utf8' | 'binary', baseDir: TBaseDir) {
  const {fp, base} = processPath(filePath, baseDir);

  return encoding === 'utf8' ?
    fs.readTextFile(fp, base && {dir: base}) as Promise<string> :
    (await fs.readBinaryFile(fp, base && {dir: base})).buffer;
}

注意到baseDir这个参数,他指定了当前操作路径相对的目录,这里我用TBaseDir指定,Books表示用户指定的书籍目录,Settings 则是用户个人配置目录,None代表传入绝对路径。当然这些目录在不同平台下的表现不一致,在桌面端由于能够允许用户自己指定文件夹,所以Books是用于配置的,Settings则是appDir

其他接口

文件系统之外就是其他接口了,其中:

  1. loadSettingssaveSettings只是存取Settings/settings.json文件。
  2. selectFolderselectBookselectNoteshowMessage都可以用@tauri-apps/api中的dialog模块解决,前三个是dialog.open,最后一个是dialog.message
  3. setBackgroundonAppHide在桌面端是不必要的。
  4. getCoverUrl本质上是一种优化,这个会在前端部分说明。

安卓端

不同于桌面端Tauri帮我们搞定了大部分事情,移动端就要麻烦不少,安卓和iOS要去分别手动实现JS到客户端的绑定,不过好在如开头所说这个选型是比较稳健的,踩了点坑还算顺利。

安卓的我用的是Kotlin,写起来没Java那么啰嗦,它的Webview用起来是比较简单的,JSBridge通过addJavascriptInterface方法配合@JavascriptInterface注解即可添加:

// 定义JSB
class AwakenJSB {
  @JavascriptInterface
  fun setBackground(r: Double, g: Double, b: Double) {
    ......
  }
}

// 注册JSB
webView?.addJavascriptInterface(jsb!!,"Awaken")

很简单是吧?那么看起来我们只要通过这个JSB实现下文件之类的接口,也没多麻烦的样子?一开始我也是这么想的,但做起来后却发现没这么简单。

文件系统

JSB有个很大的问题是它只能传输基本类型,也就是数字、字符串之类的,而不能传输二进制数据。但对于这个软件来说,电子书和封面都是二进制数据,如果全部都走JSB的话,只有一招——在一端把二进制数据转base64,到了另一端再转回来。

虽然理论上这没啥问题,对于绝大部分书籍而言(1M以内)转换的开销对于客户端或者V8 JIT加持下的JS绰绰有余,但每次都这么转一下对于我而言是难以接受的——即使到现在,我还是有那么一些完美主义倾向。

那么是否存在一种方式,能够在双端不经转换地传输二进制数据呢?仔细想想,“由前端和其他端互相传输二进制数据”,这不就是XHR或者说fetch吗?至此,我的思路便从“如何用JSB传输二进制数据”变成了“如何拦截XHR”。

在稍许调研后,我便找到了安卓Webview提供的官方拦截方法:

webView.webViewClient = object: WebViewClient() {
  override fun shouldInterceptRequest(view: WebView?, request: WebResourceRequest?): WebResourceResponse? {
    ......
  }
}

我们可以从request最后拿到请求的url,然后拆分出hostpathquery,进入下一步操作:

if (url.host.equals("awaken.api")) {
    var method: String = url.path.toString().substring(1)
    var params: MutableMap<String, String> = mutableMapOf(
        "method" to request.method
    )
    url.queryParameterNames.forEach {
        params[it] = url.getQueryParameter(it).toString()
    }

    return jsb!!.callMethod(method, params, requestHeaders)
}

这里我以awaken.api作为此类特殊接口的host标识,以path作为请求的方法名,以query传递参数,一次接口调用转换为请求如下:

fetch(`http://awaken.api/${method}?${query}`, {
  method: data ? 'POST' : 'GET',
  body: data
});

识别到此类请求后,客户端会调用jsb示例(直接复用了上面提到的JSB类)对应的方法,进行处理:

fun callMethod(
    method: String,
    params: Map<String, String>,
    origHeaders: Map<String, String>
): WebResourceResponse {
  val headers = HashMap<String, String>()
  headers["Access-Control-Allow-Origin"] = "*"
  headers["Access-Control-Allow-Methods"] = "*"
  headers["Access-Control-Expose-Headers"] = "X-Error-Message, Content-Type, WWW-Authenticate"

  try {
    处理实际逻辑......
    return WebResourceResponse("", Charsets.UTF_8.toString(), 200, "OK", headers, stream)
  } catch (error: Exception) {
      headers["X-Error-Message"] = error.message.toString()
      return WebResourceResponse("application/json", "utf-8", 200, "Error", headers, ByteArrayInputStream(ByteArray(0)))
  }
}

这里要注意我在返回的headers中都写入了允许跨域,这实际上也是为了解决后面章节的问题,而其中的X-Error-Message这个字段是为了返回错误,至于为什么么...因为iOS中的拦截无法返回自定义状态信息,安卓也只能跟着搞了。

至于实际上的文件存取逻辑没什么好说的,查一下API就完了,唯一值得一提的点是我将用户文件都存在了context.getExternalFilesDir(null)!!.toPath()取得的扩展外部存储中。

按理说到这了,文件系统应该OK了吧?既满足了需求,又能够避免base64转换,简直完美!那我只能说太天真了。在实现过程中我很快就遇到了麻烦——我无法获取到POST请求的body。并且在深度搜索的最后,也只找到了一个社区给谷歌在19年提的Issue,他们说在考虑支持然后就...没有然后了。

所以虽然很不甘心,我最终也只能做了个特殊处理——如果是安卓平台并且是writeFile接口,还是走JSBridge,也就是说在写入的情况下,二进制数据还是要经历 encodeBase64 -> 传到客户端 -> decodeBase64 的过程,具体的逻辑就不多说了也很简单。

不过最终综合来看,从前端向客户端写入二进制数据的状况只有在添加书籍时,这在移动端是个非常低频的操作,远低于读取,而读取的优化是不受这个影响的,整体仍然很赚。

其他接口

其他接口的实现就是完全通过JSBridge了,其实也没什么好说的,简单提一下吧。

首先是showMessage这个接口,实际上就是利用了客户端的AlertDialog

// 定义
mAlertDialog = AlertDialog.Builder(mContext)
mAlertDialog.setPositiveButton("OK",
    DialogInterface.OnClickListener { dialog, which -> dialog.cancel()}
)
mAlertDialog.setNegativeButton("Close",
    DialogInterface.OnClickListener { dialog, which -> dialog.cancel()}
)

// 调用
mAlertDialog.setTitle(title)
mAlertDialog.setMessage(message)
mAlertDialog.show()

select系列就稍微有点麻烦了,实际上是实现了一个通用的selectFiles接口:

@JavascriptInterface
fun selectFiles(title: String, mimeTypes: String) {
    var res: Array<String> = arrayOf()
    mContext.selectFiles(mimeTypes) {
        res = it
        mContext.mainWebView?.evaluateJavascript(
            "Awaken_SelectFilesHandler(${JSONArray(res)})",
            ValueCallback {  }
        )
    }
}

首先注意这里的evaluateJavascript,这是因为JSBridge都是同步调用,但这里面实际上执行了一个异步操作,所以为了通知前端,我执行了一个全局的JS方法Awaken_SelectFilesHandler,而对应于前端则会在调用JSB方法前设置这个全局方法的值。

而这里之所以会调用主activity的接口然后回调,主要是因为安卓机制上的限制——唤起文件选择菜单实际上是启动另一个activity,而我们需要重写主activityonActivityResult方法,来获取结果:

fun selectFiles(
    mimeTypes: String,
    callback: (files: Array<String>) -> Unit
) {
    selectFilesCallback = callback
    val intent = Intent(Intent.ACTION_OPEN_DOCUMENT).apply {
        addCategory(Intent.CATEGORY_OPENABLE)
        type = mimeTypes
        putExtra(Intent.EXTRA_ALLOW_MULTIPLE, true)
    }

    // 唤起菜单,指定状态码为4
    startActivityForResult(intent, 4)
}

// 
override fun onActivityResult(requestCode: Int, resultCode: Int, resultData: Intent?) {
    if (requestCode == 4) {
        // 处理状态码为4的结果
        ......
    }

    super.onActivityResult(requestCode, resultCode, resultData)
}

剩下的就是onAppHide方法可以监听主activityonPause周期,然后用evaluateJavascript执行一个全局JS方法即可;setBackground方法在安卓端并不需要,取而代之的是隐藏安卓的虚拟按键,这个本质上就是隐藏ActionBar并进入全屏模式,代码自己看吧没啥特别要注意的。

iOS端

iOS的接口思路和安卓是完全一致的,不过由于平台差异,实现起来还是有些不同。由于实在不想写OC了,这里我选择的是Swift顺便带上swiftui,同时为了JIT的性能用的是WKWebView。和安卓类似,我同样需要注册JSBridge和进行XHR拦截,这里先说JSBridge,XHR拦截就完全交给文件系统一节吧。

WKWebView的JSBridge注册也很简单,首先我们要定义一个实现了WKScriptMessageHandler协议的类AwakenJSB,然后将其实例注册即可:

// 实现JSB
public class AwakenJSB: NSObject, WKScriptMessageHandler, WKUIDelegate {
  init(onChangeBg: @escaping (_ color: CGColor) -> ()) {
    ......
  }
}


// 以下代码在`swiftui`对应实现`UIViewRepresentable`协议的`WebView`类中
// 实例化JSB
let jsb = AwakenJSB(onChangeBg: onChangeBg)

// 初始化JS脚本
initJS = """
window.Awaken = {
    getPlatform() {
        return 'IOS';
    },
    showMessage(message, type, title) {
        window.webkit.messageHandlers.showMessage.postMessage({title: title, message: message, type: type || ''});
    },
    selectFiles(title, types) {
        window.Awaken.showMessage("iOS设备不支持导入本地书籍,请使用其他平台操作", "error", "");
        window.Awaken_SelectFilesHandler([]);
    },
    setBackground(r, g, b) {
        window.webkit.messageHandlers.setBackground.postMessage({r: r, g: g, b: b});
    }
}
"""

// 初始化WebView
let config = WKWebViewConfiguration()
...一些初始化逻辑
let wkWebView = WKWebView(frame: .zero, configuration: config)

// 注入初始化脚本
config.controller.addUserScript(WKUserScript(source: initJS, injectionTime: .atDocumentStart, forMainFrameOnly: true))
// 注册JSB
config.controller.add(self, name: "showMessage")
config.controller.add(self, name: "setBackground")

onChangeBg回调这里暂时无需在意,其他除了流程和安卓大差不差,唯一有显著区别的就是initJS了,这是一段会在WKWebView加载完html、执行用户js前注入的一段js代码。可以看到其实际上给全局挂载了一些方法,而这些方法在安卓中是直接用@JavascriptInterface注解实现在JSB类中的。再看每个方法的实现,除了iOS无法实现文件选择外导致无效的selectFiles外,都有个调用window.webkit.messageHandlers.xxxx.postMessage,这是因为WKWebView只支持前端和客户端的异步通信,只能这么搞,好在这几个接口基本都不需要返回值,随便搞搞就行了。在jspostMessage后,我们还需要在客户端稍微处理下:

public func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
    if (message.name == "showMessage") {
        let params = message.body as! [String: String]
        showMessage(message: params["message"]!, type: params["type"]!, title: params["title"]!)
    } else if (message.name == "setBackground") {
        let params = message.body as! [String: Double]
        setBackground(r: params["r"]!, g: params["g"]!, b: params["b"]!)
    }
}

Swift是我写过的第二啰嗦的语言,苹果的API设计不敢恭维,XCode就是依托答辩。

文件系统

iOS端的XHR拦截的方式和安卓大同小异,不过这API搞起来虽然蛋疼,但人家却支持了获取requestbody...要我说你两就不能合计合计整个完全体吗?

不吐槽了...来看看怎么搞吧,iOS提供了WKURLSchemeHandler协议来为WKWebView提供XHR拦截:

// 实现协议的类
public class AwakenXHRHandler: NSObject, WKURLSchemeHandler {
    // 请求开始
    public func webView(_ webView: WKWebView, start urlSchemeTask: WKURLSchemeTask) {
        ......
    }

    // 请求结束
    public func webView(_ webView: WKWebView, stop urlSchemeTask: WKURLSchemeTask) {
        ......
    }
}

// WKWebView的那个`config`,注册拦截器,传入`jsb`只是为了和安卓一样复用JSB类实现逻辑
config.setURLSchemeHandler(AwakenXHRHandler(jsb: jsb), forURLScheme: "awaken")

这里特别要注意的、和安卓不同的是forURLScheme这个参数,它指定了一个请求的schema,因为理论上iOS不允许开发者拦截WKWebView的标准协议,类似http/https等等,所以这里我必须指定一个自定义的awaken,实际请求时为:

fetch(`awaken://awaken.api/${method}?${query}`, {
  method: data ? 'POST' : 'GET',
  body: data
});

不过在安卓上用自定义schema请求会报错,也是够麻烦的。

由于WKWebView允许获取拦截到的请求的body,所以也不用像安卓那样麻烦地去搞什么base64转换了:

let request = urlSchemeTask.request
guard let requestUrl = request.url else { return }
var method = requestUrl.path
method = method == "" ? method : String(method[method.index(method.startIndex, offsetBy: 1)...])
var params: [String: String] = [:]
let components = URLComponents(url: requestUrl, resolvingAgainstBaseURL: true)
let queryItems = components?.queryItems;
if (queryItems != nil) {
    params = queryItems!.reduce(into: [String: String]()) { (result, item) in
        result[item.name] = item.value
    }
}
let body = request.httpBody

取得了methodparams后,就可以正常处理请求并返回了,这个并没有什么麻烦的,感兴趣可以直接去项目看代码,唯一值得说道的下面几点:

其一,如何获取用户目录:

let paths = NSSearchPathForDirectoriesInDomains(
    .documentDirectory,
    .userDomainMask,
    true
);
mBaseDir = URL(fileURLWithPath: paths.last!, isDirectory: true)

其二,如何返回response。一般的教程中都会说返回URLResponse,但这个无法自定义状态码和headers,无法满足需求,在调研后我最终找到了HTTPURLResponse

let response = HTTPURLResponse(url: requestUrl, statusCode: 200, httpVersion: nil, headerFields: headers)

其他接口

和安卓相同,其它接口都是通过JSBridge实现的。

首先是showMessage这个接口,得益于iOS的天才API设计和每升一个版本就相当于另一门语言的swift,恕我懒得搞清楚它背后做了什么,直接用吧:

func showMessage(message: String, type: String, title: String = "") {
    let alert = UIAlertController(title: title, message: message, preferredStyle: UIAlertController.Style.alert)
    alert.addAction(UIAlertAction(title: "确定", style: UIAlertAction.Style.default, handler: nil))

    UIApplication
        .shared
        .connectedScenes
        .flatMap { ($0 as? UIWindowScene)?.windows ?? [] }
        .first { $0.isKeyWindow }?
        .rootViewController?
        .present(alert, animated: true, completion: nil)
}

然后是onAppHide,这个也很简单,我们首先要让AwakenJSB持有WKWebView实例,然后配合NotificationCenter实现:

public func setWebview(webview: WKWebView) {
    self.webview = webview
    NotificationCenter.default.addObserver(self, selector: #selector(didEnterBackground), name: UIScene.didEnterBackgroundNotification, object: nil)
}

@objc func didEnterBackground() {
    webview?.evaluateJavaScript("window.Awaken_AppHideCB && window.Awaken_AppHideCB()")
}

最后就是setBackground了,这个在安卓中无用,但对于大部分都是刘海异形屏的iPhone,还是很有必要的——我们需要将WKWebView放在Safe Area,顶部和底部保证和WebView的背景色一致,而这一点我最终的做法是利用swiftui的特性:

struct ContentView: View {
    @State var bgColor: CGColor = CGColor(srgbRed: 1, green: 1, blue: 1, alpha: 1)

    var body: some View {
        ZStack() {
            Color(bgColor)
                .frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: .infinity)
                .background(Color.red)
                .edgesIgnoringSafeArea(.all)
            WebView(
                onChangeBg: changeBgColor
            )
        }
    }

    func changeBgColor(color: CGColor) {
        bgColor = color
    }
}

在UI的根节点,我在底层放了一个全屏的Color控件来铺满背景,然后在上面放上自定义的WebView控件,并利用onChangeBg这个回调来修改控件的bgColor属性,最终影响到Color的颜色。而进一步,我们再将其传入一开始定义给AwakenJSB传入的那个onChangeBg回调,最终在JSB类中实现功能

func setBackground(r: Double, g: Double, b: Double) {
    mOnChangeBg(CGColor(srgbRed: r, green: g, blue: b, alpha: 1))
}

webdav跨域

至此,三端的后端接口都实现完毕,理论上为前端实现扫平了障碍,但在实际的开发中却还是遇到了因Web方案带来的麻烦,其中最大的一个就是跨域问题。

众所周知浏览器对于跨域资源是有CORS来限制的,这本质上是为了内容安全,无可厚非。但对于webdav这种协议,尤其是用户购买的私有服务,跨域本就不应该是障碍,而实际上很多厂商包括坚果云确实也没做这个限制。一开始我在用本地webdav-serverwebdav-client这两个开源库调试的时候,也遇到了跨域问题,但在大量搜索后认为是server的实现不标准,对其进行了魔改后凑合开发,但最终实际环境测试时仍然绕不过这个问题。为此我还专门联系了坚果云的开发人员,而他们的答复也很简单:“我们实现的是标准的WebDAV服务端协议”。

好了扯了这么久,困扰我的这个问题究竟是什么呢?其实很简单:对于非简单的跨域请求,浏览器在发送真正的请求前会先发送一个名为preflightOPTIONS请求,来保护不知晓CORS的老服务器,只有这个请求成功后才允许发出真正的请求

听起来很合理对吧?对于大多请求时没毛病,但问题在于webdav服务是有账号密码验证的,而preflight请求是不会携带验证信息的,而大多“按照标准实现”的WebDAV服务器在校验前并不会区分你是不是preflight请求...这TM就死锁了。

那怎么办呢?没啥办法,既然浏览器的限制绕不过去,就只能借由客户端了,毕竟客户端并没有CORS。如此一来,就不得不给我们上面提供的XHR拦截多加个方法了。不过这块处理起来,比上面那些接口要更复杂一些。

前端拦截

首先因为是用的开源库(我这么懒显然不想去拉一份下来自己改),所以只能看有没有hook的方案,而这时候选择Hybrid方案的优点就体现出来了——轮子多。我使用了ajax-hook库来在前端拦截XHR,将所有webdav请求都加上了一个prefix来协助拦截:

export const DAV_PREFIX = 'http://AwakenWebDav:';

proxy({
  onRequest: (config, handler) => {
    if (!config.url.startsWith(DAV_PREFIX)) {
      return handler.next(config);
    }

    const url = config.url.replace(DAV_PREFIX, '');
    ...接下来的代理操作
});

hookXHR后,接下来就是在不同平台实现请求代理了。

桌面后端

首先是桌面端,在上面接口的实现中,桌面对文件系统并不依赖与XHR拦截,所以这里要额外想怎么实现。好在Tauri官方已经给我们准备好了由rust实现并绑定好的http模块。

首先在tauri.conf.jsontauri.allowlist中配置:

"http": {
  "all": true,
  "request": true,
  "scope": [
    "https://**",
    "http://**"
  ]
}

随后在代码中简单实现即可:

// 引入
import {http} from '@tauri-apps/api';

// 实现代理
http.fetch(url, {
  method: config.method as any,
  body: config.body ? (typeof config.body === 'string' ? http.Body.text(config.body) : http.Body.bytes(config.body)) : undefined,
  headers: config.headers,
  responseType: /(png|epub)$/.test(url) ? http.ResponseType.Binary : http.ResponseType.Text
}).then(res => {
  handler.resolve({
    config: config,
    status: res.status,
    headers: {},
    response: res.data
  });
}).catch(error => {
  handler.reject(error);
});

安卓后端

移动两端做法基本一致,为前面的XHR拦截协议新增方法webdav,然后将真正请求的url作为请求的参数传入客户端即可:

if (config.body && platform === 'ANDROID') {
  const isBase64 = typeof config.body !== 'string';
  const data: string = isBase64 ? atob(config.body as ArrayBuffer) : config.body;
  jsb.setWebdavRequestBody(url, config.method, data, isBase64);
}

fetch(`${API_PREFIX}/webdav?url=${encodeURIComponent(url)}`, {
  method: config.method,
  body: config.body,
  headers: config.headers
}).then(res => {
  const errorMessage = res.headers.get('X-Error-Message');
  if (errorMessage) {
    throw new Error(`${errorMessage}: webdav(${url})`);
  }

  return (/(png|epub)$/.test(url) ? res.arrayBuffer() : res.text()).then(data => {
    handler.resolve({
      config: config,
      status: res.status,
      statusText: res.statusText,
      headers: res.headers,
      response: data
    });
  });
}).catch(error => {
  console.error(error);
  handler.reject(error);
});

但在最后的处理中双端还是有一些差异:

首先安卓端由于前面说过的原因,需要将二进制body转换为base64,所以在客户端需要实现一个JSB接口setWebdavRequestBody,在请求前将转好的base64发给客户端,这个在上面的代码也有所体现。接下来在客户端只需要将请求的url作为key,把base64存到一张Map中,后续接收到请求取出处理即可,无序赘述。

其次就是请求代理的本质是将从Web拦截下的请求由客户端发出,再将结果返回Web。而客户端发出请求时,应当带上原先请求的headersbody,很遗憾我并未在安卓的官方API找到能满足需求的接口,所以最终我使用了okhttp3这个库:

fun webdav(url: String, method: String, headers: Map<String, String>): okhttp3.Response {
    val request = okhttp3.Request.Builder().url(url)
    val cache = mWebdavRequestCache[method+url]

    if (cache == null) {
        request.method(method, null)
    } else {
        if (cache.second) {
            request.method(method, Base64.decode(cache.first, Base64.DEFAULT).toRequestBody())
        } else {
            request.method(method, cache.first.toRequestBody())
        }

        mWebdavRequestCache.remove(method+url)
    }

    headers.forEach {
        request.addHeader(it.key, it.value)
    }

    return client.newCall(request.build()).execute()
}

吐槽一下kotlingradle这些工具和依赖他们的库之间的各种依赖冲突真是蛋疼。

iOS后端

iOS这边比起安卓要更简单一些,其原生的URLRequest就可以满足需求了,在上面的XHR拦截入口前加上一个分支即可:

if (method == "webdav") {
    let url = URL(string: params["url"]!)!
    let session = URLSession.shared
    var req = URLRequest(url: url)
    req.httpMethod = request.httpMethod
    req.httpBody = body
    req.allHTTPHeaderFields = request.allHTTPHeaderFields
    let task = session.dataTask(with: req, completionHandler: {[weak self] data, response, error in
        guard let strongSelf = self else { return }
        if (error != nil) {
            strongSelf.postFailed(to: urlSchemeTask, error: error!)
        } else if (data != nil) {
            var res = (response as! HTTPURLResponse)
            var headers: [String: String] = [
                "Access-Control-Allow-Origin": "*",
                "Access-Control-Allow-Methods": "*",
                "Access-Control-Expose-Headers": "X-Error-Message, Content-Type, WWW-Authenticate"
            ]
            strongSelf.postResponse(to: urlSchemeTask, response: HTTPURLResponse(
                url: requestUrl, statusCode: res.statusCode,
                httpVersion: nil, headerFields: headers
            )!)
            strongSelf.postResponse(to: urlSchemeTask, data: data!)
            strongSelf.postFinished(to: urlSchemeTask)
        }
    })

    task.resume()
    return
}

前端实现

前端逻辑主要是书籍和笔记的管理与同步,堆业务逻辑嘛懂得都懂,无非都是说难也不难,说简单却也坑多有些麻烦。所以这里我不会说太多细节,只会捡重点说一些心得。

为了加快开发效率(其实是懒),这里我选择了以前用惯了的React作为前端框架,样式用SCSS配合css-modules,并配合17年在B站用爱发电期间和同事一起开源的hana-ui,效果还行吧。

已经两年多没写正儿八经的前端代码了,有些手生,第一次用hooks玩不太转,而且也懒得用什么状态管理库了硬莽,不得不服老啊...嘛...又不是不能用。

书籍管理

首先是书籍管理,也就是维护书籍列表,这也是软件刚进去的首页。

协议

要维护书籍,首先就需要定义好书籍的数据结构:

export type TBookType = 'EPUB';

export interface IBook {
  hash: string;
  type: TBookType;
  name: string;
  author: string;
  ts: number;
  removed?: boolean;
  cover?: string;
}

这里面最重要的是hash,它是电子书本身的md5,保证了书籍的唯一性,之所以特意算一遍hash是因为书籍本身可能重名而且也不一定都有ids元数据。在实际的存储中,我会给每本书籍创建一个以hash命名的目录,将具体内容存于其中。

type是书籍类型,之所以有这个的理由你应该猜到了...不错,一开始我想支持EPUB/PDF/MOBI等多种书籍格式,但最后发现太麻烦不值得花这么多精力就只剩EPUB了(没事毕竟我们还有针对PDF的OCR和转换神器calibre嘛...又不是不能用。

nameauthor是从电子书提取的书名和作者,没啥好说的。ts记录的是书籍被修改的(添加/删除)的时间戳,配合removed在同步时使用,毕竟咱没有中心服务器处理逻辑,为了避免同步错乱没啥办法。

最后一个cover是从书籍中提取的封面,方便首页展示。我在添加书籍时,会将封面的二进制数据提取出来存到书籍目录下,之后在每次软件启动拿到书籍信息后,会根据当前平台自动生成一个cover地址。

在桌面端,我们需要在tauri.conf.json中的tauri.protocol字段中进行配置,允许软件访问用户本地文件:

"protocol": {
  "asset": true,
  "assetScope": [
    "**"
  ]
}

之后利用接口转换地址即可:

import {tauri} from '@tauri-apps/api';

getCoverUrl(book: IBook): string {
  return tauri.convertFileSrc(`${BOOKS_FOLDER}/${book.hash}/cover.png`);
}

而在移动端就简单了,复用之前实现过的文件接口协议即可:

getCoverUrl(book: IBook): string {
  return `${API_PREFIX}/readBinaryFile?filePath=${book.hash}/cover.png&base=Books`;
}

同步

书籍列表的同步从原理上其实挺简单的,但要处理的边界情况稍微会有点麻烦,其本质上可以规约为:

拉取远端列表 -> 和本地列表对比 -> 拉取远端新增书籍 -> 上传本地新增书籍 -> 同步列表目录

具体的逻辑就不写了,有兴趣的可以自己去看代码。这里值得特别说明的有几点:

首先是书籍冲突,其原因很简单,因为本方案没有一个中心的逻辑服务器,逻辑是分布在各个设备单独处理的,同时添加本地书籍并不需要联网,这就可能导致各端分别添加了同样的书籍,然后一并同步到远端的状况,这又会带来两个可能的问题:

  1. 本地尚未上传的书籍已然存在于远端。这个问题从设计层面被解决了:因为书籍的唯一性由hash决定,一般来讲不会碰撞,所以这里不涉及到状态。
  2. 两个设备同时将本地书籍同步到远端。这个问题是切实存在的,理论上来讲可以通过webdav协议的lock方法解决,但考虑到实际会这么操作的可能性很低(喜欢在日常中COS测试工程师的用户不在其中,我也没义务考虑),所以就这么办吧,不处理。

其次是书籍的删除,同样是由于没有中心服务器的缘由,书籍的删除变得非常麻烦。我不能直接把条目移除覆盖到远端,这样会导致有两个设备都存在统一条目的情况下永远删不掉。所以我退而求其次,即便是删除了也保留条目,然后用书籍协议中的removedts字段,选择时间戳比较近的,然后判定是否被删除:

const syncToLocalBooks: IBook[] = [];
remoteBooks.forEach(book => {
  const localBook = localTable[book.hash];
  if (
    (localBook && (book.ts > localBook.ts)) ||
    (!localBook && !book.removed)
  ) {
    syncToLocalBooks.push(book);
  }
});

const syncToRemoteBooks: IBook[] = [];
books.forEach(book => {
  const remoteBook = remoteTable[book.hash];
  if (
    (remoteBook && (book.ts > remoteBook.ts)) ||
    (!remoteBook && !book.removed)
  ) {
    syncToRemoteBooks.push(book);
  }
});

最后是错误处理,由于我的设计中支持批量添加书籍,那么在中途任一环节被异常中断都是可能的。这个为了避免状况复杂化,我的选择是:直接退出,下次同步时直接重新处理,对远端直接覆盖上传。严格来说这样确实不是最优解,但本身就是低频操作,代价也还行吧。

EPUB解析阅读

在书籍列表点击任一封面后,进入的就是阅读界面了。这里我使用了epub.js这个开源库,虽然文档一般坑不少,但却是能解决绝大多数的问题,至少不用从头去再造轮子了。

这一部分吧...虽然算起来是前端最复杂的一部分,也搞了挺久,但其实也没有太多好说的,大部分看epub.js的文档就OK,值得重点提的几个地方:

首先初始化,对书籍的初始化大致逻辑如下:

const book = ePub(props.content);
rendition = book.renderTo('epub-viewer', {
  width: '100%',
  height: '100%',
  stylesheet: props.bookStyle,
  allowScriptedContent: true,
  allowPopups: true
});

这其中要注意的是allowScriptedContentallowPopups的设置,由于epub.js使用iframesandbox模式渲染,所以要启用所有功能必须开启。而stylesheet会在下面的主题切换一节说到。

其次是分页,众所周知页码是用来分割传统书籍的内容的,而EPUB这种电子媒介中并没有这个东西。对于Kindle而言,页码其实是一种额外信息,由亚马逊特别处理还原传统书籍照顾读者习惯或者方便引用。而我显然是拿不到这种信息的,所以只能按照业界一般的估计,以600字每页来分割书籍,这自然是一种不严谨的做法,但也凑合吧:

const pages = await book.locations.generate(600);

这样看起来似乎OK了,但如果只是这样,你会发现每次进入阅读都会很慢,因为生成页码是个非常耗时的操作。好在epub.js提供了一个口子,我们只需要生成一次pages,然后将其存下来,之后每次进入时读取即可:

// 存储分页数据
async savePages(book: IBook, pages: string[]) {
  return await bk.worker.fs.writeFile(`${book.hash}/pages.json`, JSON.stringify(pages), 'Books');
}

// 加载已存在的分页数据
book.locations.load(pages);

有了分页,自然就要考虑页面跳转,但这个其实没有这么简单。如果用户已经体验过阅读模式,可以知道页面的跳转有以下几种:

  1. 上一页/下一页:通过键盘左右,或者点击左右空白处。
  2. 进度条:拖动或者点击进度条,快速跳转。
  3. 目录跳转:在目录界面,点击章节标题跳转。
  4. 笔记/书签跳转:和目录类似,不过是点击笔记或书签列表。

而这些跳转,都是通过一个函数实现的:

const jump = (action: EJumpAction, cfiOrPageOrIndex?: string | number | IBookIndex) => {
  if (action !== EJumpAction.Page) {
    rendition.on('relocated', updateProgress);
  }

  if (action === EJumpAction.Pre) {
    rendition.prev();
    return;
  }

  if (action === EJumpAction.Next) {
    rendition.next();
    return;
  }

  if (action === EJumpAction.CFI) {
    // first, jump to chapter
    rendition.display(cfiOrPageOrIndex as string).then(() => {
      // then, jump to note
      rendition.display(cfiOrPageOrIndex as string);
    });
    return;
  }

  if (action === EJumpAction.Index) {
    rendition.display(idToHref[(cfiOrPageOrIndex as IBookIndex).id]);  
    return;
  }

  if (action === EJumpAction.Page) {
    rendition.display(rendition.book.locations.cfiFromLocation(cfiOrPageOrIndex as number));
    return;
  }
};

函数开头的relocated方法监听,是为了在进度跳转后,向上一级同步页数。下面就是根据不同状况做的区分处理了:

  1. PreNext:针对普通切页操作,直接使用prev()next()方法切换页面,这里不能用生成的页码是因为针对不同设备和画布,一屏显示的内容并不对应一页
  2. Index:针对目录索引,idToHref可以通过book.loaded.navigation处理得到。
  3. Page:针对通过进度条修改的页码,可见是转换到CFI处理。
  4. CFI:这个比较特别,将在下一节详细论述。

笔记、书签和进度

这一部分的逻辑算是阅读部分最为复杂的,由于需要同步,所以和上面的书籍列表同步有相似的问题,但由于需要注意顺序,状况更多更麻烦一些。

协议

首先还是要定协议,看下面两个接口:

export interface IBookNote {
  cfi: string;
  start: string;
  end: string;
  page: number;
  text?: string;
  annotation?: string;
  // timestamp
  modified: number;
  removed?: number;
}

export interface IBookConfig {
  ts: number;
  lastProgress: number;
  progress: number;
  bookmarks: IBookNote[];
  notes: IBookNote[];
  removedTs?: {[cfi: string]: number};
}

其中IBookConfig是每本书的配置文件,存于目录的config.json中:ts是更新时间戳,progress是本地进度,lastProgress是远端最新进度,这三个配合起来可以做进度同步。bookmarksnotes分别是书签和笔记列表,removedTs则是为了解决笔记的删除问题,无奈下特意的一个优化用对象。

IBookNote则是笔记或书签的数据结构:cfi/start/end这三个都是CFI下面会解释,page是根据cfi算出的页码,textannotation是笔记专有的标注的文本和用户输入的注解,而modified/removed是时间戳,用于记录当前设备删除/修改一条笔记的时间,用于同步。

CFI

上面多次提到了CFI,那么这到底是是个什么东西呢?让我们看看官方解释:

This specification, EPUB Canonical Fragment Identifier (epubcfi), defines a standardized method for referencing arbitrary content within an EPUB® Publication through the use of fragment identifiers.

简单来说,CFI或者说epubcfi,就是用于表达对EPUB电子书中某一段内容的引用。我们可以可以利用它完成对电子书中任一片段的定位索引,其形如epubcfi(/6/12!/4[3Q280-46130e5d9d644673954c13edca4fc20f]/4,/1:325,/1:340)。具体的定义我不再赘述,有兴趣可以直接看Specification,这里只讲我如何利用其完成的笔记功能。

首先是书签,在上面我们提到过relocated事件会更新进度,这个事件会返回一个location,通过其我们可以得到需要的信息:

props.onBookmarkInfo({
  start: location.start.cfi,
  end: location.end.cfi,
  cfi: mergeCFI(location.start.cfi, location.end.cfi),
  page: loc, modified: Date.now()
});

location.startlocation.end分别是当前显示内容的起始和结束CFI,我自己写了个方法mergeCFI将它们合并起来备用。而在onBookmarkInfo的处理中,我并没有直接将其作为待处理的信息直接交由书签标记逻辑,而是先用另一个方法进行了处理:

const parser = new EpubCFI();
export function checkNoteMark(notes: IBookNote[], start: string, end: string): INoteMarkStatus {
  if (!notes.length) {
    return {exist: false, index: 0};
  }

  for (let index = 0; index < notes.length; index += 1) {
    const {start: s, end: e, removed} = notes[index];
    const cse = parser.compare(start, e);
    const ces = parser.compare(end, s);

    if (ces <= 0) {
      return {exist: false, index};
    }

    if (cse <= 0) {
      return {exist: removed ? false : true, index};
    }
  }

  return {exist: false, index: notes.length};
}

这个方法传入一个书签或者笔记,返回其是否存在,以及在当前列表中的位置。为什么要做这个处理呢?因为前面说过——书签和笔记都是有序的,所以我们不能随便插入了事,要先知道插到哪个位置。而有了位置信息,接下来的逻辑也就水到渠成了。

比起书签,笔记的处理要更麻烦一些,因为其不是一个定点而是片段,同时还需要让用户自己去选中这个片段。UI层面的交互我就不多说了,无非就是堆点逻辑,其中比较重点的是如何获取选中的文字片段。如果你去查文档,它会告诉你在rendition中有个selected事件可以解决,但事实上不行,我在阅读了源码后最终只能得到一个Hack的方案——在locationChanged事件后,获取到当前的content,在其上注册事件:

rendition.on('locationChanged', () => {
  content?.off('selected', selectNote);
  const c = rendition.getContents()[0];
  c?.on('selected', selectNote);
  c !== content && setContent(c);
});

而这个事件处理器selectNote的逻辑也很简单,就是将其传入笔记标注工具组件。在这个组件中,我对每个传入的CFI判断是否为新,如果是的话使用checkNoteMark计算出其在当前笔记列表中的状态和位置,然后再计算出工具栏在页面上的位置:

const range: Range = content.range(cfi);
const {x, y, width, height} = range.getBoundingClientRect();
const cw = document.getElementById('epub-viewer').clientWidth;

setX(x % cw + width / 2);
setY(y + height / 2);

然后就可以按照工具栏上的功能写逻辑了,比如删除笔记、修改注解等等。添加/删除笔记同时也伴随着高亮的标注,这个倒是比较简单:

// 添加
rendition.annotations.add('highlight', cfi, undefined, undefined, 'awaken-highlight');

// 删除
props.rendition.annotations.remove(note.cfi, 'highlight');

其中awaken-highlight是主题的一部分,后面会说。

功能到这差不多完备了,但还有点体验上的细节:

其一,一般来讲,对于一段已经标注好的笔记,我们往往希望点击它就可以弹出笔记工具栏,而不是需要选中。此时renditionmarkClicked事件就可以帮助我们。

其二,如果你看了epub.js关于文本选择的源码,可以发现其处理非常粗暴:以选择开始为起点,在150ms后判定结束返回事件,而这做法显然不合理,所以我做了点优化(当然仍然不想改工程,凑合改了):

const EVENT_NAME = bk.supportChangeFolder ? 'mouseup' : 'touchend';
(Contents as any).prototype.onSelectionChange = function(e: Event) {
  const t = this as any;

  if (t.doingSelection) {
    return;
  }

  const handler = function() {
    t.window.removeEventListener(EVENT_NAME, handler);
    const selection = t.window.getSelection();
    t.triggerSelectedEvent(selection);
    t.doingSelection = false;
  };

  t.window.addEventListener(EVENT_NAME, handler);
  t.doingSelection = true;
}

修改很简单,将选择结束的条件改为mouseup或者touchend就行了。

同步

书签和笔记的同步在内容上比书籍简单,因为只需要合并两个数组,但由于其有序并且存在同一条目的更新(修改注解),而且量可能较大还要考虑效率,所以更加麻烦。不过好在这也不是特别复杂的算法,凑合写了个大家看看就懂:

private _mergeNotes(localNotes: IBookNote[], remoteNotes: IBookNote[], removedTs: {[cfi: string]: number}): IBookNote[] {
  const res: IBookNote[] = [];
  let localIndex: number = 0;
  let remoteIndex: number = 0;
  let pre: IBookNote;
  let less: IBookNote;
  let preRemoved: IBookNote;

  while (localIndex < localNotes.length || remoteIndex < remoteNotes.length) {
    const local = localNotes[localIndex];
    const remote = remoteNotes[remoteIndex];

    const comp: number = !local ? 1 : !remote ? -1 : parser.compare(local.start, remote.start);
    if (comp === 0) {
      if (local.modified < remote.modified) {
        less = remote;
        remoteIndex += 1;  
      } else {
        less = local;
        localIndex += 1;  
      }
    } else if (comp === 1) {
      less = remote;
      remoteIndex += 1;
    } else {
      less = local;
      localIndex += 1;
    }

    // local
    if (less.removed) {
      removedTs[less.cfi] = Math.max(less.removed, removedTs[less.cfi] || 0);
      preRemoved = less;
      continue;
    }

    // remote
    if (preRemoved?.cfi === less.cfi) {
      if ((removedTs[less.cfi] || 0) > less.modified) {
        continue;
      }
    }

    // remote
    if (pre?.cfi === less.cfi) {
      pre.modified = Math.max(pre.modified, less.modified);
      continue;
    }

    if ((removedTs[less.cfi] || 0) > less.modified) {
      continue;
    }

    res.push(less);
    pre = less;
  }

  return res;
}

其中每个noteremoved属性仅存在于本地条目,而到了远端则变成removedTs中一部分。这么做首先是由于和书籍同步同样的原因,我必须要在远端存下来某个条目是否被删除了;其次不像书籍一样直接同步removed字段是由于一个笔记占用开销,同时按照上面这种优化实现逻辑上会出问题。

如何合并完远端和本地的笔记与书签后,本地存一份然后立即同步到远端,搞定。

Webview默认行为

Kindle笔记迁移

有了笔记功能,别忘了我最初是为了什么搞这个软件的——从Kindle迁移。所以终于到这一步了,就是如何将Kindle的书迁移过来,笔记也迁移过来。

Kindle的书籍迁移已经有很成熟的教程了,可以参考:一键批量下载 Kindle 全部电子书工具 + 移除 DRM 解密插件 + 格式转换教程 (开源免费),最后用calibre转换的时候选择EPUB即可。

接下来就是笔记迁移了,亚马逊提供了几种笔记导出方案,本来想都支持的,后来由于时间不够懒得搞了就只支持从桌面端软件(PC/Mac)导出的笔记,详细教程搜一下就有,最后导出的应当是名为XXXXX-笔记.html这样的文件。

这里吐槽一下亚马逊的同行们,你们连个html文件的输出都拼不对我真是服了...是因为html本身容错率太高导致看了下能渲染就没检查了是吧?

导出的笔记文件格式分析我就略过了,经过我的解析处理后,大致可以得到如下信息:

  1. 笔记的标注内容文本,去掉了空格填充。
  2. 如果有注解,注解文本。
  3. 笔记的“位置”。

看到这,读者可能觉得最直接的方法就是将笔记的“位置”转换为EPUBCFI就行,特别简单对吧?哪有这么好的事...在经过查询后,我大概了解了Kindle是怎么计算“位置”的:每128个字节算一个“位置”,这也就解释了为什么你复制中文文本甚至导出的笔记里都会插入这么多空格(我猜的),所以这路子行不通,那可咋整呢?

想来想去,最后还是只能用原始暴力的方法——文本搜索匹配。我直接把笔记拿出来全文搜索出那个片段总行了吧?正好epub.js也提供了对章节的find接口。但在满怀期待试了以后,发现...问题更复杂了:

  1. 脚注:很多书中都会有“xxx[1]yyy”这样的脚注,而由于EPUB本身是xml结构的,脚注会被单独渲染为一个结点,但搜索是基于结点的,直接去搜索根本搜不到。
  2. 段落:和脚注差不多的原因,搜索基于xml结点,跨段落直接跨结点了,搜不了。
  3. 重复:同一段文字会多次出现,对于笔记的“金句”而言不多见,但还是可能在译者评论中出现。

为了解决这些问题呢,我搞了个挺恶心的算法,具体太长就不贴了有兴趣自己去看吧,大概说下思路:

  1. query的前N个字获取rangeStart,后N个字获取rangeEnd,最后合并。
  2. N看实际情况来取,而textEnd需要在textStart(无脚注link)/linkEnd(有脚注link)的若干结点之内。
  3. 先判断是否有[\d+]的link,没有的话:
    1. 小于六个字的,直接全文搜索。
    2. 大于六个字的,拆成前六后六两部分,分别搜索后合并。
  4. 有的话
    1. 先查找到第一个link,然后反向搜索link前的文本的前(小于等于六个字),没有文本则直接以link为起点。
    2. 然后找到最后一个link,正向搜索link后的文本的最后(小于等于六个字),没有文本则以link为终点。

但即便如此,仍然无法覆盖所有情况,不过大部分情况已经可以覆盖了(只要你处理的是中文书籍),对于边界情况,我会收集起来在最后弹窗提示用户,并复制到用户的剪贴板。

主题切换

至此,主要的功能逻辑都搞定了,但由于个人的习惯,搞了这么久不加点私货是不可能的,所以主题切换功能就此诞生,其属于阅读设定的一部分:

export interface ITheme {
  name: string;
  color: string;
  background: string;
  highlight: string;
}

export interface IReadSettings extends ITheme {
  theme: number;
  fontSize: number;
  letterSpace: number;
  lineSpace: number;
}

由数据结构便可以看到阅读设定允许的内容:fontSize是文本大小,letterSpace是字间距,lineSpace是行间距,这几个单位都是rem。而theme就是最重要的主题了,name是主题名,color是文本颜色,background是背景色,highlight是标注高亮、注解、外部链接颜色。

如何修改这些字段的UI逻辑没必要赘述,这里比较重要的是如何让这些参数产生效果。还记得前面说过的后端的setBackground接口,以及初始化电子书时的stylesheet字段吗?setBackground配合阅读界面的背景颜色,来让异形屏客户端的非安全区统一颜色,这个没啥好说的。stylesheet则可以给渲染EPUBiframe添加一个css文件,而这个文件是我动态生成的:

export function buildStyleUrl(settings: IReadSettings): string {
  const style = `
body {
  color: ${settings.color};
  font-size: ${settings.fontSize}rem;
  line-height: ${settings.fontSize + settings.lineSpace}rem;
  letter-spacing: ${settings.letterSpace || 0}rem;
  touch-action: none;
  -webkit-touch-callout: none;
  word-break: break-all;
}

img {
  width: 100%;
}

a {
  color: ${settings.color};
  text-decoration: none;
  border-bottom: 2px solid ${settings.highlight};
}
  `

  return URL.createObjectURL(new Blob([style], {type: 'text/css'}));
}

当用户每次修改完样式确认后,我还需要去修改重新设置样式:

const applyReadSettings = async (rSettings: IReadSettings) => {
  const {background, highlight} = rSettings;
  await bk.worker.setBackground(
    parseInt(background.substring(1, 3), 16) / 255,
    parseInt(background.substring(3, 5), 16) / 255, 
    parseInt(background.substring(5, 7), 16) / 255
  );
  const sheet = document.getElementById('global-style') as HTMLStyleElement;
  sheet.textContent = `g.awaken-highlight {fill: ${highlight} !important;fill-opacity: 0.5;}`;
  setBookStyle(buildStyleUrl(rSettings));
  setReadSettings(rSettings);
}

其中global-style的修改是为了笔记高亮的样式(epub.js是用SVG实现的),而setBookStyle后执行的则是:

rendition.themes.register('awaken-style', props.bookStyle);
rendition.themes.select('awaken-style');

卸载之前的样式重新加载即可。

构建发布

开发完成后就是最终的构建发布了,这个在三端也有不同的做法。当然无论如何,第一步都是构建出生产环境的代码,我将最终代码构建到了dist目录,以备后续处理。

桌面端

桌面端的构建很简单,Tauri都帮我们想好了,我在tauri.conf.json中指定了资源目录build.distDir./assets,并用脚本将上一步构建后的产物复制到其内:

d=platforms/desktop/assets && rm -rf $d && mkdir $d && cp dist/* $d

最后执行切换到桌面工程目录下执行构建代码即可:

cd ./platforms/desktop && tauri build

最终的产物在./platforms/desktop/target/release/bundle内。

安卓端

移动端相较于桌面端,需要自己区分开发和生产环境,而且对资源的处理也要更复杂一些。

在安卓端,首先区分开发和发布环境是通过菜单的Build -> Select Build Variants窗口设置的,默认有debugrelease两种模式,在不同模式切换后会有个全局单例中的变量BuildConfig.DEBUG能判断处于什么环境:

if (BuildConfig.DEBUG) {
    ......
}

接下来在发布时,我们现将构建好的前端代码复制到指定目录:

d=platforms/android/app/src/main/assets && rm -rf $d && mkdir $d && cp dist/* $d

至于如何返回这些包内静态资源呢?也很简单,在前面我已经在客户端实现了Webview中XHR的拦截,现在只要在里面添加一些逻辑即可。

首先,在发布环境下,我需要将Webview加载的url从调试的dev-server的地址,换到我们拦截的schema

mainWebView?.loadUrl(if (BuildConfig.DEBUG) { host } else { "http://awaken.api" })

然后对于所有在之前接口协议之外的method,全部认为是对包内静态资源的请求,然后进行拦截处理:

fun loadAsset(url: String): InputStream {
    return mContext.assets.open(if (url == "") { "index.html" } else { url })
}

可见在安卓上对包内资源的请求是很简单的。在完成这些后,我们还需要给应用提供一个签名,用Build -> Generate Signed Build or APK即可。

最终构建产物在platforms/android/app/release中。

iOS端

iOS的工程配置是完全由XCode管理的,单纯从构建区分来讲并不复杂。

首先是区分开发和发布环境,这个只需要在工程配置文件的Build Settings -> swift compiler - Custom Flags -> Other Swift Flags中,给Debug配置加上-DDEBUG,给Release配置加上-DRELEASE,然后创建两个构建的Schema,在其中分别使用哪个环境,最后就可以在代码中使用这些编译选项了:

#if RELEASE
......
#else
......
#endif

接下来在发布时,我们现将构建好的前端代码复制到指定目录:

d=platforms/ios/Awaken/assets && rm -rf $d && mkdir $d && cp dist/* $d

随后在XCode项目配置的Build Phase -> Copy Bundle Resource中,将assets目录添加进去,让其能打入构建包。随后便可以在代码中的发布分支中编写具体逻辑了:

let rp = Bundle.main.path(forResource: "index", ofType: "html", inDirectory: "assets")!
do {
    let str = try String(contentsOfFile: rp, encoding: .utf8)
    wkWebView.loadHTMLString(str, baseURL: URL(string: "awaken://awaken.api")!)
} catch {
    wkWebView.load(URLRequest(url: URL(string: host)!))
}

可见这里比安卓要复杂不少,我先读取了本地Bundle内的index.html,然后指定了我们实现了XHR拦截的地址awaken://awaken.api作为baseURL。接下来WKWebView就会以这个地址为基础去请求JS、CSS等静态资源了。

之所以要提前读取html的内容,是因为通过XHR拦截返回index.html会出现问题。

静态资源的处理和安卓基本一致,对于不满足既定接口方法的请求直接尝试加载本地资源即可:

private func loadAsset(url: String) throws -> FileHandle {
    let fp = url == "" ? "index.html" : url
    let nameExt = fp.components(separatedBy: ".")
    let rp = Bundle.main.path(forResource: nameExt[0], ofType: nameExt[1], inDirectory: "assets")
    let file = FileHandle(forReadingAtPath: rp!)

    if (file == nil) {
        throw "File load error: \(url)"
    }

    return file!
}

结语

搞之前没想到还挺麻烦的,要是之前有人搞了我也不会花着精力,毕竟这甚至不属于我既定项目的一部分。不过总体来讲,做完还挺有成就感的,未来有别的项目也可以作为模板打个样。

无论如何,我过去从开源社区已经索取了这么多,那么尽可能做出力所能及的回报也是必要的。无论做点啥总都比躺着伸手祈求强,毕竟——

意义并不在于‘包罗万象’的观测,而存在于任一行动的回响之中。

接下来就是去读书然后完成既定的主线项目了。

Project Self

作者 dtysky
2022年4月30日 08:00

褐色的枝干攀附于浅黄的墙壁,粉白的花簇拥着吊在其上,油绿的树叶反而成了点缀。风不大,偶有几片花叶被吹落到地上,笔直的水泥路灰里透着青,一直向远处的湛蓝天空延伸。我站在墙下抬头望去,阳光逆着我的视线透过树叶,照在了我带着蓝色美瞳的眼睛上。

“对对对,就是这个姿势,保持一下!老天赏脸,这光真不错。”身后的摄影师@夏昊(以下简称X)背着他的富士GFX100S,语气轻松:“你今天状态感觉还好吧?”

X和我同龄,几年前毕业于北影,据说刚毕业时吃过一阵子肉,但前些年电影行业开始不景气就离开了这行当。好在他家境不错,这两年便自由在家学习感兴趣的东西,不时出来给有缘人拍些写真赚点钱。

“还好,虽然感冒还是有点。”我顶着些许的身体不适,努力保持着这个姿势。保持,意味着静止,而完全的静止,对我而言是不可能的。虽然肉体可以尽量不动,但无法停止的思绪却开始了游离。

这段时间我似乎一直在感冒,说是感冒,其实就是持续性的头晕加嗓子不舒服。症状一般始于起床,午饭后逐渐缓解,晚上到家又开始。前几天我似乎开始适应了这种不适,但今天特别需要一个良好状态,所以出门前饭后我磕了个对乙酰氨基酚片。和年少时不同,现在的我不再会硬抗肉体的不适来获取某种悲壮的情绪。数次实践下选出的最适合自己的药,让我现在的症状相比早上拍和猫的合照时缓解了许多。

想到了猫,便想到了早晨,对早晨的回想接替了感冒的主题。而按照惯例,这思绪又进一步跳跃,回到了我和X首次沟通的那天。

沟通-起点

沟通,对于我而言可以很简单,也可以很困难,这取决于具体的内容。当然,每个人都有自己擅长或不擅长沟通的领域,但像我这么极端区分的,应该是不多。以前我会将这些领域内容区分为很多种类,比如技术、工作、二刺螈、文学、哲学、情感问题等等,但现在想来,我大概仅会将其分为两类:取悦他人,或是取悦自己。

我擅长取悦自己,即便是往常那对自我的折磨,很多也可以说是一种变相的取悦。但取悦他人,的确是我极不擅长的,所以我一般会尽力避开那些取悦他人的场合,来让自己得到最大的安宁。倘若所有的事情都能被如此干脆得划分,倒也不失为一种有益的秩序。但现实的本质既是混乱,又怎可能事事顺遂?现在摆在眼前的,便是一个特例。

写真作为一个特例,包含了取悦他人和取悦自己的双重性质。这个取悦的他人倒不是说摄影师,而是源于我拍摄的初心:一个妹子对我社交账号照片的建议。她说“从女方视角,很多男的都很卷,各种花里胡哨,你也应该搞点让大家喜欢的照片。”我觉得蛮有道理,便认真考虑起了这个建议。“考虑”这件事本来并不是什么大事,我每天本就在考虑许多无关紧要的屁事,但前面加上“认真”二字就不同了。

认真,在我的词典里优先级是极高的。任何事一旦加上了这个前缀,便会直接跃升为一个大工程,我会尽自己的所有可能将其做到最好。写真这事说起来,一开始不过是为了搞几张取悦他人的照片,按理说花个几百块拍套简单的、温柔的、沉稳的艺术照即可。但既然认真了,就需要考虑将其维度延展开。这一认真,一延展,一加工,我便又给自己挖了一个坑。和我之前定下的诸多类似《Project Heart》、《Project Tomorrow》、《Project Fake》等等企划一般,我脑海里突然浮现了一个企划。

这个企划的名字便是《Project Self》,而其核心便是三个人,或者说是三个人格,亦或说是三个投影比较合适:少年,少女,青年。

不错,这三个投影,正是在我过去写给自己的私小说中,那数次出现的少年H、少女H和青年H。如此一来,这写真就成了一种记录和表达,那么它和写作也就相去无几。而我的写作,无论目的如何,取悦自己的比例一直远高于取悦他人。考虑到这,写真的初心就变了个味,从取悦他人变成了取悦自己。

拍写真取悦自己,意味着需要高度的定制,定制又意味着预算和增加和详尽的沟通。对于现在的我,相比于预算这种小事,沟通显得额外艰难。作为一个INFP,我是比较擅长自我剖析和暴露,也逐渐不在乎他人的不理解,但此次却不能如此。如果将其也视为一种创作,那么和往常我一人构思和执笔不同,这一次我是将笔交予了对方。它不但要求摄影师完全理解了我的诉求,还要求对方能够认同,这样才能由衷给出那种表达。

我写了一篇需求来描述我的意图,将其发给了几个朋友介绍的本地摄影大佬,但都被拒接了。其中有一位给出了“这个只能让一个比较熟悉你的摄影师”来的建议。循着这建议,我找到了这个深圳的北影出身的知乎KOL摄影师朋友。将需求发给他想让他介绍个广州的圈内朋友给我后,他便饶有兴致得以“我就喜欢创意型摄影”自己接下了这个单。

摄影的报价是我给出的,出于对朋友的信任和避免某些无效的沟通,我咨询了圈内朋友后,给出了圈内的一般最高价格——三四千,对方表示可以:如果结果不满意三千,满意四千,而且排期自由。我觉得如果能达到要求,也算合理,便定了下来,具体时间则是在清明。

我本来认为做好了这些前置准备,便只需要为了取悦自己而沟通,如此一来效果的达成也就是时间问题。但事后想来,取悦他人还是自己不过是结果论罢了,对于这样的一种非常主观的活动,本质上是双方需求的对抗与中和。对抗和中和,也就是沟通的另一种说法,但在这个侧面而言,沟通的实质其实是“要求”。

极其不擅于提出“要求”的我,让拍摄饶了一些弯路。

第一日

拍摄的第一日,正是清明的第一天。由于感冒症状以及昨晚摄影设备和电脑的调试,我大约九点醒来,却挣扎到了十点多才起床。不过打开窗户后,和煦的阳光让我心情好了不少,天公赏脸,今日并未像预报一般是阴天。换了衣服走出卧室后,惯例的低血糖让我有些头晕,便当即决定吃了饭再出门。饭后磕了药,身体状况并未瞬间好了起来,但时间已然不早,我也不想拖延更久,便和X说先带着猫在小区内拍几张日常,之后再正式开始今天的外景。

在这所有的拍摄之前,X先问了我一句:“看你的需求,到底是想要美化的,还是比较真实的?”

我不假思索得回到:“我不想做一个伪物,所以当然是真实。”

于是真实便成了这系列拍摄的主基调,在这个基调下,我们带着滚滚和糖糖下楼了。

小区坐落在海珠和番禺两个区的交界处,位置可以说是偏僻到鸟不拉屎,但其另一面也确实有足够大的面积、很低的容积率与不错的绿化。我们站在单元门口一眼望去,是一条被错落有致的树木包裹着的林荫小道。此时万里无云,明媚的阳光正透过树叶,向被砖红色和青涩投下粒粒光斑,非常适合取景。我们向前走了几步,X观察了周围,很快就确定了个地点。我走了过去,将猫抱了出来,开始了拍摄。

“就这是吧?”城市来讲,如果可以列一个我不擅长的事情列表,“拍照”应该是能排在很前列的。所以在拍摄时,我第一想的便是专业的事交给专业的人,尽量遵从对方的指导。

“再前面一点,对,就这几个光斑下面。”他指着地上的几个光斑,我带着猫走了过去。

“糖糖!别瞎跑。”生性好动谨慎的糖糖刚下地就想着躲起来,她体型很小又白,匍匐在地,东张西望,见缝就钻。好在虽然我身体仍然不适,但在三年多的相处下早就学会了对付她。我将比较傻不拉几的滚滚放在一边,迅速拎起了糖糖的脖子,将她放回原地。如此重复三四次后,她可能暂时得习得性无助了,便不再反抗。

拍照在搞定了猫后顺利了起来,这组写真的主角是猫而不是我,所以我个人在镜头前的特质并没有表现出来。同时在和猫的互动中,我的表情相对自然并且还有些许笑意,这掩盖了一些本应出现的麻烦,让我们认为接下来的拍摄都会如此简单。

第一组照片,就这样完成了。

""

少年

“你过来看下效果吧。”

“...好的。”

X的声音将我从回想中拉回,我走了过去,看了看他相机中的照片。情绪有点复杂,但还是回了句:“感觉不错,挺有日系清新的那个味。”

""

情绪复杂,不是在于对方拍摄技巧不好,也并非由于本应出现的那个麻烦:它还尚未出现。问题是在于,这和我本身设想的感觉确实不太一样。在我的设想中,摄影的第一个阶段是“少年”,而我的少年时光,与大众意义、也可能是X所理解的少年感,还是有不小差距的。

我对少年的标签,是桀骜不驯,唯我,以及脆弱,其模板如下:

""

而X拍摄出的,是包容、阳光和温暖。

此刻我本应提出一些要求,但虽然时常在文章或是其他场合不掩饰自我的特质,但在这种一对一的、我所不擅于应对的场合中,我将主导权完全交由了对方。“要求”此刻对我而言成为了非常困难的事情,便出于尽可能减少“沟通”的想法,选择了沉默。

我们就这样相对沉默,顺着笔直的道路向前走着,寻觅着下一个拍摄的场所。走了不一会,我们便停在了一片露营地旁。他指了指不远的一处草丛,说:“这不错,你去坐到这个地方。”

我便走了过去,很配合得坐了下来,之后又按照X的提示摆好了姿势。这里和方才不太一样,人多了不少,大多还都是妹子。虽然表面上力保那种体面和从容,我的心中还是有些不是滋味。即便是在社会上找到了许多自信的我,还是很不适应那种非常社会化的审视目光,这并非出于怯懦,而是出于一种由羞耻心和厌恶感交织而成的复杂情绪。

抱有这样情绪的我,望着对面的他,感觉他手中的相机就像是一挺枪,将周围的那种审视浓缩成了对我特攻威力巨大的子弹,要将其射入我的胸口,铭刻我此时的惨状。

“你能笑一笑吗?”他一边端着相机对着我,一边向我示意应当摆出的表情。

“我尽力...”我当然可以理解他说的含义,那也是世俗“少年感”的一部分,所以我尽可能尝试去这么做了。而从此刻开始,那个源于我特质的麻烦也就出现了。

""

不一会,我们在这一块换了几个地点,拍了几张照片。整个过程中我们的气氛也逐渐缓和,不再那么沉默,随便聊了聊周边环境,广州的生活气息,甚至还有照片中那个可乐售卖机引发出的一系列关于时代感标语的探讨。然而拍完这一组后,我们的话题还是最终落在了那个麻烦的地方。

“你是不太放的开的那种性格是吧?”X语气轻快,开玩笑般向我问道。

“嗯,我是比较严肃。”

“确实,你这个嘴角一直都是这样”他说着比了个︵的手势:“的,总有种生人勿近的感觉。”

“哎,那也没啥办法,尤其是在相机前。”我无奈的口吻发自真心:“虽然以前恋爱开心的时候确实也︶过不短时间。”

“那即便是单身,你也可以把这种状态延续下去吧,从你的一个目的来讲,这样会有更多人喜欢你,阻力也小一些。”

“可能吧,不过既然你也创作过,应该可以理解︵的时候比较容易出好东西,而︶的时候一般是创作不能的。所以...”

“OK,我理解了,那就按照这种感觉去拍也好。”虽然他这么说,但我明白他应该是理解了他心目中的那种少年感应该是无法达成了。

下一组照片决定了在江边拍摄。我们过了马路,靠着石质的栏杆,眼下便是缓缓流淌的珠江。在江的对岸是数座林立的高楼,那便是广州的CBD珠江新城,也是这个城市房价最贵的地方。而此时不知是巧合还是命运,我的旁边,正是一个被放在栏杆墩子上的路灯。路灯虽然很小,但看到了它的我还是会心一笑。我不知道X是否也有这样的想法,但他最后敲定的地点就是这里。

“好就这个姿势,眼睛看向十点钟方向。”

我坐到了栏杆上,将双腿交叉,双手自然搭在两侧,按X所说望着十点钟的方向,静静等待着他的拍摄。十点钟的方向,是天空,天很蓝,云很少。群青的天空,CROSSING,群青学院广播社,孤独,这样一来确实有些我认为的少年氛围吧。所以至少在这一刻,我确信我应当展露出了少年的情绪,而当最终拍摄结束时,我看到样片时也终于确定了:我的少年时代,大概确实如此。

""

拍完这组后,X提议这个形态室外差不多了,可以换个内景试试。离这最近的比较适合内景的,自然是广东博物馆。我们便回到了方才的那片露营地,绕着它走向博物馆,而在这个途中,一串泡泡在我们眼前飘过,X便有了想法:“你觉不觉得这个很有意思?”我虽然没有完全会意到,但觉得也算有趣,便和他一起走去,完全没有意识到前方的艰难:密度更高的人群,和他人的配合,更多注视着我的人,还多了小孩和他们的家长。不过最后的结果还挺好,虽然我仍然没能笑出来。

""

过度

到博物馆时已经快四点了,我们被管理人员告知今日预约已满,便只好考虑接下来的行程。我原定的规划是去TIT,回工位换衣服后直接进入青年状态,在园区内取一些景物后入夜再到海心桥上拍夜景。但X的想法却有些不同,他建议中间加一个“过渡态”,来体现少年到青年的这种转变。

我想了一下,感觉这个提议确实有道理。一来我确实带了三身衣服备用,二来如果说白日对应少年,黑夜对应青年,那么傍晚黄昏作为过度,也确实是一个绝妙的象征。于是我换上了第二身衣服,银白的休闲西裤,米黄色的衬衫,蓝灰色的休闲西装外套,加上黑框眼镜,恰好对应了白到黑的一个渐变,也体现了少年/青年特质的部分过度——

尚未完全颓废的桀骜不驯,尚未演变成疏离的脆弱,尚未完全伪装起来的自我

拍摄始于一个比较文艺的咖啡馆,我们的座位侧面是一个挺帅的、打扮讲究的小哥,后面则是看起来在暧昧的男女。X提议为我在这里拍两张照片,他拿起相机拍我的时候,我感觉其他几个人又审视起了我,这让我不太舒服,效果也就不太好。所以在些许休息后,我们终于走出了这里去了外景,我便松了一口气,恢复了状态。

第一批正式的照片是在不远处拍的,TIT整个园区的绿化都做得很好,找一个枝叶繁盛的背景并不困难。我按照指导站在了一簇簇树木和花草之前,背对着它们后方的双层Loft:微信的访客中心。此刻已经接近傍晚,透过枝叶的阳光没有了之前的凌厉毒辣,变得温暖且柔和。我在X示意下,大概构想了一下应当摆出的姿态,结果仍然是理所当然的克制和拘谨。而后接着这个情绪,我们找个了比较僻静的角落,顺着台阶一路往上,又拍了几张表现出日常和带着颓废的照片。

""
""

但这几张更多是体现了那种和解和隐忍,而忽视了反抗,不过即便意识到了问题,我也并未直接提出诉求,而是在接下来的过程中找机会从我这边调整。幸运的是接下来的两个场景非常恰恰到好处,虽然比起达到“被人喜欢”,它们更多的应当是引起大多人的厌恶,但这种审视和倨傲比起白日的那些照片,我认为更能体现我所言的“少年感”,也是我认为少年真正应该拥有的气质。

""
""

拍完后面这张后,X对我说:“是不是有你给的那个广州市人民医院的例子的feel了?”我粗略看了下效果,确实如他所言,相比起来病态的气质少了一些,但多了些肃穆的氛围,总体在我看来拍得很好,却也没有过多的感想。但当事后我再次打开它,我便明确了这是我今天最喜欢的一张照片

些许杂草和青苔丛生在脚下的木板上,像一张破败的地毯。红砖砌成的墙壁充满着岁月的痕迹,横竖两道水泥交叉着嵌在其中,像是半截巨大的十字架。我就站在这十字架的正下方,昂着头,宁静得闭着眼,任由斜射在墙上的阳光从半张脸上掠过。

如此一来,这个阶段就算是结束了。我提议先去客村那边吃个饭再回来换衣服进入下个阶段,二人便向那边走去。而在快要到目的地时,X却停下来,并把我叫住,我回首一看,他正望着边上这条城中村小道,若有所思。我走了过去,没想到今天最考验我羞耻心的瞬间就如此开始了:在这个最为繁华的时刻,城中村的小道行人和车辆络绎不绝,他让我站在入口的正中间,逆着人流,尽可能摆出姿势停留...我照做了,于是今天他最喜欢的一张照片就这么诞生了。

""

“我有这么胖,脸有这么方吗?”裸高173,体重62,照镜子认为自己是鹅蛋脸的我,看着这张被像是特稿封面的照片,不禁陷入了沉思。

青年

晚餐后已是黄昏尾声,我们回到了TIT换了最后一身衣服,进入今天最后的一个形态——青年,也就是现在的我。从最初的白天,到过渡的黄昏,我们终于来到了符合这个阶段的象征——夜晚。不过在去到规划中的拍摄地点之前,我们先拍了张搞事的照片——《某大厂被毕业无助青年》(逃

""

以及水中的倒影:

""

接下来便去到了二沙岛海心桥旁的艺术公园,开始了预定的行程。到岛上时,暮色已经缓缓降临,远方的天空自上方的紫色逐渐向下渐变为橘红,挡在前面那树木错落的枝丫在夜色的浸染下通体黝黑,静静得沉默着。我们拉着箱子过了马路,在路边观望着来来往往的人群,顺便寻觅着第一个拍摄地点。而就在此时,我忽然感受一股袭来的倦意,便毫不讲究得坐在了一根石柱上,将手撑在了行李箱上。

“就这么来两张吧。”我下意识对着X提议到,于是第一张青年阶段的照片就这么诞生了。事后再看回来,它应当是准确得体现出了我想表达的青年阶段特质颓废,伪装,疏离中的“颓废”:

""

坐了一会后,我恢复了一些精神,二人便顺着人流继续向前走,不知不觉就来到了海心桥边。海心桥横驾于珠江之上,被其上众多暖白的灯光的映照着,在这夜色中熠熠生辉。在它之下的珠江被微风带起了阵阵涟漪,水面上的倒影随着波纹摇摇晃晃,让身为半个图形程序我不禁在心中感叹:“这渲染果然还是TM得让三次元来啊,这反射,这高光...”

而就当我这么想的时候,X示意我去往桥边不远处的护栏前方。我走到了那里,背靠护栏,看着他走到了我的左前方,便随着他的视线向右后望去。在立即明白了他的想法后,我微微侧头望向了十点钟的方向,尽可能表现出了第二个特质“伪装”。

""

随后又在珠江边的另一处拍了张青年的隐藏特质装傻(笑

""

按原计划来说,下一个地点本应是海心桥,但这个点还排的十分长的队伍证明了我的失策,也打消了我们上去的念头。折返后是漫无目的的闲逛,疲惫的我们穿过了公园,越过了马路,正准备就此回家的时候,却意外发现了一块树上满挂着红灯笼的地方。他停了下来,示意我站在这片草地中间,按下了快门:

""

“你这张很像我的美术老师,二十年后再用吧。”他示意我看着照片。

“嗯...确实有点显老。”但无论如何,至少这张确实大概表现出了第三个特质“疏离”。

至此,第一天的拍摄结束,感冒药的药效似乎也完全过了。我脑袋有些昏沉,便拒绝了X邀请我去晚上的一个舞会以“认识圈内朋友”的邀请,打车回到了家后,又让司机将他送到了舞会现场。而在车上是,他提到了付款方式,我表示用大额资金习惯用支付宝,随后我问了句“你现在就要还是?”他表示“先给吧,少点利息。”于是出于信任和与人为善,我便将于先说好的三千先转给了他,最后的一千等全部完成了再给。

第二日

按照计划,本来应当是第二天就立马去棚里拍第三个形态“少女”,但由于X约定的另一个客户的突发时间安排,时间确实相对比较自由的我便调整了时间,改为了次日的下午。

前奏

大约下午四点半,我到了摄影棚,发现在场的除了X,还有一对情侣——这正是X的另一个客户,据说是他交了许久的朋友。既然他们还在这里,也就意味着还没拍完。起初我坐在沙发上看了他们一小会,觉得确实还蛮般配,尤其是知道他们异地了不少时间最终成了眷属的情况下。

但过了不久,我便坐到了化妆台前,由X联系的化妆师给我化妆。

化妆

由于接下来有个场景还是需要少年/青年两个形态出境,所以我先画了个简单的淡妆,遮了个瑕,然后补了个少年/青年的形态,随后便进入了正式的化妆环节。

化妆师应该是个三十多的姐姐,看起来已经有多年行业经验了。她一边用娴熟的手法给我上着妆,隔离、打底、遮瑕、修眉、假睫毛、眼线等等,一边还和我搭着话。

“你这是搞COS嘛?”

“啊?也不算吧,就是明年就三十了,给自己留个纪念。”

“三十?看起来一点都不像。”她笑了笑:“感觉就和二十出头一样。”

“谢谢,很多人都说我显年轻。”我已经不在意对方是不是奉承,说到底这也根本不重要。

“不过你这想法挺好的,我都三十好几了,那时候也没想着给自己留个纪念。”

“嗯...我是觉得还是得留下点啥吧。”完全是反射性的回答,我也不知道自己在说什么。

时间就这样过着,过着,直到最后遮瑕的那一步。

“小伙子你这皮肤有些差啊,遮瑕估计是没办法完全遮住的。”

“嗯,最近在做激光试试,你尽力吧。”虽然表现得若无其事,但内心还是有些刺痛吧,但确实也没什么办法,我的基因和出身又不是我能决定的。

“没事没事,反正现在有灯光和后期,你这个只是在妆下会显得明显些。男汉子粗糙点也正常的。”

“嗯...”再次反射性的回答,我当然知道对方这是出于礼貌,但我也不想因为这礼貌产生任何感激。

不过无论如何,虽然没有感激,却也没有憎恨和厌恶,纯粹是没有感觉。没有感觉,意味着麻木,但对于某些事情的麻木,对于我而言,可能并不是一件坏事。

少女

化妆大概在半小时左右结束,后面化妆师还很靠谱得帮我带上了美瞳和假发。在确定没什么问题后,我按照她和X约定的价格,将三百转了过去,随后换上了第一身衣服——白色LO,并走到了摄影棚的中心,形态“少女”的拍摄正式开始。

这个棚是一个白棚,和我预想的不太一样。我本来以为回到一个实景棚,里面布置成一个温馨的小房间之类的,有些道具来互动,但这里除了白一无所有。不过既来之则安之,我不擅长提要求,也很讨厌麻烦,便只能对着X和后面到来的他的助理小妹妹,摆起了姿势。

然后在几张样片后我可悲得发现,即便是穿上了女装,我也没有办法回到几年前穿LO时的那种纯洁可爱的感觉了。

“这个,我有这么胖吗?”我指着样片,又看了看镜中的自己,感受到了明显的反差。

“......”X似乎忙了一天,很疲惫,没有做过多回复。

“算了,靠后期吧。”

当然,事后我查了一些资料才知道“动眼效应”,图像的扁平化也确实会让人显得更胖,不过还是可以靠一些角度和修饰来解决的。但此刻的我只能无奈回到了原地,继续拍那个“祈祷”的姿势,进而完成了“三位一体”的拍摄:

""

后续我又尽可能找感觉来远离我一贯的那种苦大仇深,进而体现出少女设定中温暖,纯真,善良的特质,但尝试了若干张之后,我才终于算是有了一张勉强有感觉的:

""

而此刻的我也将这种显胖的原因甩给了“白色”,众所周知,白色的衣服本就容易显胖。于是我连忙换上了那身暗红色的日常LO,走到了镜头前。但在拍了几张后,感觉表情和神态还是不怎么对。于是X提议将镜子放在我的身边,以此来让我注意自己的动作和神情,而在这种优化的操作下,我像是忽然找到了那种日常的自我,出了几张感觉不错的图,虽然仍然没有笑容:

""

日常这身拍完后,便是最后一身GOTH系的LO,设定里是由“白”反转而成的“黑”。在换上了这身衣服后,对着镜子的我感觉才找到了真正的自我,那种不协调的违和感瞬间消失,我不需要去强行伪装出任何表情,只需保持日常的不屑和高冷即可。也正因此,这一身的出片品质应该是今天最高的:

""

晚餐

拍摄大约在九点左右结束,此刻的我已经非常疲惫,匆匆卸妆收拾完后便准备回家。但在这之前,我下意识问了一句:“你们吃了吗?”

“还没有,那你请我们吃饭吧。”X如此回应。

“OK,我找找吧。”我觉得大家确实也辛苦了一天,即便这一天里只有一半是在为我辛苦,但只要结果好,也无妨。

于是很快我便打了个车,前往目的地:最近的一家海银海记。

“那我就按我的习惯点了。”落座后,我按照习惯,将那老几样——三花趾,五花趾,吊龙,胸口朥,千张,粿条等加入购物车下了单。然后大家便闲扯了起来。

“你是他带的学生,所以是准备考北影嘛?”我起了个话头,问了问对面看起来比我高的小妹妹。

“嗯,考明年的。”

“明年...你今年本科毕业嘛?也就是00的?”

“嗯...我01的。”

“......”虽然我一直号称自己永远年轻,抵制成长,但当一个真的01年应届生坐在我面前时,还是下意识感受到了自己的老去。

“挺好,不过现在这环境,还去考电影是真爱啊,你本科是传媒吗?”

“不是...是工科的。”

“读研要三年,这不景气也不能一直这样吧,就赌一下三年后的环境。”X接着话茬,阐释着对方的选择并非没有道理。

“说的也是,反正这年景哪行都不行,互联网也裁员,读研避避风头也好。”

接着顺着这个,我们便聊起了疫情防控,进而聊到了康米,又转进到了情感问题,接下来又是青春与理想,然后是人的异化...

这诸多可说与不可说,宏大与鸡毛蒜皮,严肃和消失与腹中的牛肉。事后想来,也算是这个时代特有、却又在漫长的历史中有着诸多共性的,极其有趣、讽刺又悲哀的场面吧。

毕竟那些种种神圣和憧憬,最终都不过是饭桌上的谈资罢了。

回顾-要求

摄影结束后便是漫长对修图的等待,在这等待中我看着这些原片,回想着这两天的拍摄,说是十分满意肯定是不可能的。而想来这各中的不满的源头,应当正是铭刻在我身上那和“要求”这个词的不适性。

众所周知“要求”是双向的,一个人提出,一个人接受或者否决。而倘若在一段重要的关系中一个人的要求总是被否决,那么提出要求的这个人便会渐渐沉默,这关系便会走向终结——按常理而言应该如此。但这世界上却并非所有关系都能被单方终结,在这个奴隶已经不存在的世界...不,90后的很多亲子关系,和主人奴隶之间的关系可谓神似。在许多家庭中,孩子在成长中逐渐都患上了习得性无助,便失去了“要求”的能力,或是即便没有失去,却也觉得“要求”本身是错误和可耻的。而我,自然也是其中的一员。

我非常不擅长提出要求,取而代之的则是表面的讨好人格,与实际上“沉默->消耗忍耐度->结束关系”的链路。即便在毕业后终于离开了家中的六年中,我也只是学会了在职场合理得提出要求,在生活中即便是非常努力去改变,却也只是收获寥寥。这极大限制了我的能力边界,也让我白白遭受了许多无谓的伤害。

这些伤害可大可小,我可以举出很多很小却可笑的例子。最近一次印象深刻的小事,是在公司咖啡店发生的。往常在我点了热柠檬茶后,店员一般会在上面套上一个隔热的套子,来防止烫手。而不知为何,从某一天后,店员就不会再主动套这个套子了。没有了套子的柠檬茶,握在手上,十分得烫,但在相当的一段时间呢,我的做法都是拿起就走。不错,即便这温度让我感到十分不适,我仍然忍耐着握着它走到了两百米外的办公区。

就这样过了数次后,某天的我又像往常一样点了个热柠檬茶。而下一瞬间,我并未直接拿起它就走,而是像忽然开窍一般,拿起了旁边的一个套子,自己套上了。看着此时手中的柠檬茶,我并没有往常那样灼热的触感,这让我恍惚了一下,然后忽然意识到了什么,接下来便是盘旋在我脑海中的一句话:

“他不给,你就不能要?他妈的我凭什么不能要?”

伴随着这句话,过去二十八年的种种场景便如潮水一般灌入了我的脑海。其中记忆最深刻的,应该是我大概在大二的某个夜晚。那晚我忽然感受到了强烈的牙痛,那是一种钻心的疼痛,让我坐立不安。但我第一时间想得居然不是去医院,而是忍着,是喝凉水,是用纸团顶着它,是用种种手段来抑制疼痛,以此期望它自己过去,不要带来更多的麻烦。即便是后来实在忍不住去了医院,我一开始也没有告知父母,而是想着通过自己的生活费省吃俭用省下这就医的钱。直到被医生说很严重,不得不花几千块钱来做烤瓷牙套时,我才不得不打电话给父母。

如此正当的一个要求,当时的我却至始至终觉得自己像个贼一般,不配得到这样的治疗,浪费了他们的钱。但时候细想,他们缺这点钱吗?并不缺。我的牙变成这样是我自己造成的吗?并不是。那为什么我会感到这么耻辱?那么我只能认为这是从小的各种经历和教育,让我养成了一种我之前并未意识到的性格:

厌恶和他人争执,厌恶麻烦别人,厌恶请求他人。不错,我并不是不懂得“要求”,只是不喜欢“请求”罢了。因为请求意味着亏欠,而我不想亏欠任何人。所以我只能用一些其他的策略来让他人认为“应当主动给我”,而不是“我向你要的”。但显然,这种手段只对于亲密关系能产生作用,而且基本都有着严重的副作用。对于商业化的交换,这最终只能导致自己吃亏的同时,还憋着一肚子气。

所以回归到这次拍摄来看,我这“不愿请求”的习惯,确实让我们的拍摄饶了一些弯路。但好在后续和朋友的沟通下,他们给出了一些分析和建议,说“你既然支付了成本和精力,那应当需要让自己满意”。这让我再次意识到了我去“要求”的正当性和必要性,于是便联系了X,来为了补上第三日的拍摄:室内,以及一个实景棚的剧情编排。

准备

定下了第三日拍摄日程,便要开始准备了。准备分为两个部分,其一是对家中的收拾,让其变得更加整洁以适应拍摄。

家中的收拾倒是没什么,也确实该来个大扫除了。我对这种事的启动成本虽然一向比较高,但一旦启动了就会尽可能做好。从卧室飘窗开始,书桌,床上,客厅沙发,电脑桌,餐桌...垃圾收拾得差不多后,我开始拿着吸尘器处理灰尘。从地面,到书架,再到电视柜,以及架在电视柜两旁的音箱...

“...这是?”我停了下来,望着其中一个音箱上的日历。这是去年公司发的新春礼品的一部分,而其上的日期则一直停留在了2021年的7月份。

“......”脑中忽然闪过了一些回忆,它们一件件稍纵即逝,却又连绵不绝:“你追求留下痕迹,我追求飞过和体验”,“你追求的是无以轮比的闪耀”,“可是代价呢?”......

“行了行了,瞎几把想啥,干活。”把日历叠起来扔到垃圾袋后,房间很快便被我顺利打扫干净了。

收拾房间后的第二件事,则是推演和购置棚拍所需要的道具。吸取上次的经验,以及按照编排的剧本要求,我列了一个需要购置的物品清单。按理说这些东西应该是平平无奇,网上随便就能买到的,但实际过程却非常艰难,也让我学到了一些奇怪的知识。

首先是在黑色男装形态手中的那把枪,我本以为搞一把没有实际发射功能的、一比一的模型枪应该不难,但一开始搜遍全网,却都只能买到1:2.05的模型。在纳闷之中我查了下原因,才知道现在对枪模的管制已经如此之严格,哪怕是外形一样没有功能都不行,为数不多可以等比例的枪模还必须报备,交由专业的剧组团队保管。我虽然可以理解这政策,但着实也带来了很多麻烦。当然好在经过大量的搜索和碰巧,最后我还是找到了一把金属外壳、等比例的、可以发射软弹的左轮手枪,也算是有个好结果。

然后是GOTH女装形态手中的《圣经》,我本来只是想着搞个道具,想着这问题应该不大吧即便没有那买本书拿着也不是不行,结果无论是淘宝还是京东一搜索都发现根本没有结果。于是我在纳闷中又去查了下原因...嗯,原来如此,那也没招。最后我只能用家中那本海德格尔的《林中路》来凑合替代一下。

再者在国内电商网站上和“圣经”有着相似待遇的,还有手持的金属十字架。我已经不想再去查原因是啥了,就直接放弃了这个构思,换了种构图。

总之虽然遭遇了意想之外的麻烦,但也勉强算是准备好了所需要的道具,它们被我收拾了起来,静待着拍摄日的到来。

专访直播

在这第三次的拍摄日程前,还插入了一段《腾讯程序员》视频号对我的专访。这个专访缘起于今年二月内部的一个邀请,但由于疫情一次次推迟,直到现在才终于定下了日期。虽然还有少许风险,我还是决定义无反顾去了直播室。这首先是源于我对不可控性的厌恶——那是早已准备好的事情的一再延误,所派生出的那种悬而未决的感觉。再者,则又是一个宿命般的巧合,因为我忽然意识到——这次专访,不正好也可以算作是这《Project Self》的一部分吗?

“写真”静态地从外侧记录了我疏远和断绝于这个世界那封闭的“自我”,“访谈”则动态地自内部挖掘了我连接于这个社会那开放的“外延”。

直播的整体来看算是成功,即便是一开始由于我的小概率事故体质导致除了一些故障(笑),让直播整体延期了半个小时,导致流失了一些观众,但最后数据还是不错:

直播访谈录屏

访谈后续公众号发文

访谈结束的第二天,便是最后一日的拍摄了。

第三日

第三日比原计划晚了一些,但也正碰巧——那是近一周天气最好、没有雨的一天。这次拍摄起前两次有两点区别。其一是我叮嘱了X别拍那种文艺的感觉,早上就搞一些日常的记录,下午就好好拍剧情编排;其二,则是这次多了一个特别的摄影助手。

这个摄影助手并非和上次一样是X的学生,而是我的一个朋友。这位中大文学在读博士朋友(下称S)曾经鸽过我两次(笑),由于认知上相对接近,比较聊得来,所以认识了快半年还会不时聊聊天。当之前得知我要拍写真时,她觉得这种创作很有趣,就说要来给我当助理,但由于疫情封校遗憾错过。所以得知我还要补第三次,而她正好也能出校,便二话不说做好了计划。

日常

我们大概在九点半起了床,此时早晨阳光正好,我将滚滚从阳台抱了出来,开始了第一个场景的拍摄。在这个场景中,我的本意是体现出我在做独立游戏的感觉,显示器上的内容也正是如此:一个32寸的屏幕分成了四块,一块是Unity,一块是写逻辑的VS,一块是写剧本VSCode,一块是框架文档。

然而X似乎并没有回到我的意,而是直接从左前方,也就是显示器的背面进行了拍摄。当然我是可以理解从我侧后方拍的话,位置比较憋屈,所以也没有多说什么。我照计划装模作样敲起了文字,按理说这场景很快就应该过去,但这臭猫不知道犯了什么病,开始折腾。

“滚滚,你在干啥这么不配合?”我一边按住它的身子,一边将其拖回原位:“你个臭猫,我好吃好喝伺候着,看病花了我四五万,就配合我拍个父慈子孝都不乐意?”

但滚滚似乎并不能听进这些道理,反而更加变本加厉得想逃跑。

“哎...”看来它不吃硬的,只能来软的了。我将其抱回了原位,抚摸着他的头和下巴,让他逐渐冷静了下来,虽然还是满脸不高兴,但至少勉强静止,于是照片就这么诞生了:

""

第一张拍完后,剩下的几张倒是没什么特别好说的,打游戏那张的糖糖虽然也想跑,但好在比较小和轻,自然是逃不出我的魔掌(笑:

""
""
""

正片

拍完早上的日常照后已是接近中午,不一会S便到了小区楼下,我在接她的同时也去楼下趟花店,买了束郁金香来作为今天最后的道具。一切准备就绪,我们就打上了车,前往今天本来定好的目的地——81号摄影棚。

81号摄影棚是一个广东COS摄影圈内的朋友推荐的,我看了看它家的微博宣传图,觉得里面教堂这个景挺不错,后续虽然也和另一家对比了下,但由于感觉这家的态度比较好,也没想太多就定了下来,其收费是:两小时起订,一小时120,定金150。

本以为到了地方三个多小时即可结束今天的计划,但实际到了这个坐落于城中村后的棚却让我大跌眼镜:三楼小小的场地,随意凌乱摆放的破旧道具,不伦不类的两侧幕布,以及甩手不过问任何布置的老板。我们在不断调整布景,来来回回忙活了一个多小时后,大家都达成了确实没办法达到我想要的效果的共识,于是当断则断支付了两个小时的费用后及时撤离了。

撤离后的我们站在路上,已是接近下午三点。我们徘徊在原地,不知如何是好。按照我的规划,找一个真是的教堂,例如“圣心大大教堂”自然是最合适的,但这种地方显然不会让我们进去。

“要不我先申请入教,然后去拍,拍完了再退教?反正我比他们大对信徒对上帝的理解更深刻。”

“你可别扯了,人家是要让你信的,谁让你理解了。”

“哎...”

犹豫,踟蹰,打趣解决不了实际问题。但我忽然灵光一闪,找到了之前那家觉得态度不好而放弃的棚,在S一个电话得到对方回应后,我们便打了个车,火速去了那里,然后发现这确实是一个质量不错的实景棚。

“虽然还是不大,但这40平的实景棚看起来已经是广州能找到的最适合的地方了,就今儿拍完吧。”我们做出了决定,拍摄就这样开始了。

准备

这最后一次的拍摄,重点在于“情节”,情节需要演员出境铺就,而这些演员则是我的多个人格。既然是多个人格一起出镜,那必然就会伴随着技术上的合成,而合成,则需要固定的机位和固定的站位。

为了达成这个目的,我事先准备好了标签纸和记号笔,同时做好了每个情节、每个角色的序号编排,例如第一个场景的白衣少年是“WB S1”,第三个场景的黑衣少女是“BG S3”等等...写好这些标签后,我就需要把它们贴到地面上,来标记到时候我拍摄应当站在的位置。

图为S配合我演绎情节中的站位,同时我贴标签的场景:

""

准备结束后,拍摄便正式开始。我总共换了四身衣服,来表现四个人格,而每个人格也基本都有符合自己设定的单人照,以及最后那用于合成的十个场景。至于具体剧情,我就不文字描述了,只能说...我尽力了。

单人格

""
""
""

多人格

""
""
""
""
""
""
""
""
""
""

结语

经过了将近五个小时的漫长拍摄,除了最后的合成,整个企划终于宣告结束。老板人还是不错的,最后收了三个小时的钱,一小时180,加上妆娘的150,加起来共700。为了庆贺,我请二人吃了顿火锅,在整个庆功宴和最后的返程中,我们又闲聊了许多琐事:我个人在理想和现实中游走平衡的焦虑,X事业和生活间的挣扎和平衡,S学业和规划上的压力等等。想来这次聚餐与上次最大的区别,大概就是在于01年的学生妹子换成了94年的在读博士妹子吧,年龄相仿的三人在一起,讨论的话题自然也就现实了许多。也是因此,我再次意识到:无论外在和人设怎样表现出一种青春少年,但人生阶段确实是不同了。

在回到家中后的第二天,我整理出了S帮我站位的一些花絮发给了她,聊起整个拍摄过程,她直言:“我觉得你的优点挺多的,尤其是细致和认真。”

面对这个这个评价,我只能无奈一笑:“原来我展现给别人的是这样一种值得褒奖的品质吗?”作为一个标准的INFP,我太清楚自己的拖延和惰性了,但早已根植于我内核的“决定了就一定要做好”的诅咒,却让我不得不花很大精力去反抗我的本能。这一次次细致的规划和设计,无不伴随着极大的焦虑和数次的失眠,客观来讲这对我的身心造成了不小的负担。不但如此,我之所以会这么早就做好尽可能全面的规划,同时事必躬亲不喜欢他人插手,应当还是出于一种深刻的“不信任”——

似乎从某个时刻起,我就无法由衷得对任何人产生信任了,无论是朋友,还是父母,亦或是其他时刻的我自己。

自相矛盾,却又强行保持一致。有憧憬光明向其奔跑的一面,也有封闭于漫长黑夜隔绝一切的一面。是一个能很好运用逻辑和理性工作的青年,也是一个拥抱非理性在荒诞和虚无中挣扎的少年。口口声声说着完成最优先,却又无法真的脱离完美主义的陷阱。不断努力尝试扎根于现实的生活,却还是只能悬于空中无法真正得落到地上。

这样的我未来会成为一个怎样的人呢?会像五年前、十年前的我对现在的我的态度一样吗?我不知道。但我能确定的是,几年、十几年、甚至几十年后的我看到这个项目,一定会产生一个疑问吧:“这个时刻的我,也就是你,究竟是想记录什么,又想表达些什么?”

我现在能给出的回答是:“这些照片和访谈都是次要的。最重要的,是这整个记录的过程,是表达自我时的那些态度和情绪,是这不断重复的憧憬、尝试、失望、挣扎、渴求、补偿,以及在精疲力尽后却并仍不完美、满含残缺的‘结局’。”

“正如,你自己的一生。”

我为什么写作

作者 dtysky
2022年2月2日 08:00

本篇内容是《回顾2021》的最后部分。

一开始起这个标题的时候,我是觉得配不上。毕竟早有奥威尔出版的同名自传在前,而我写作水平都尚未成熟,更遑论作为一个作家,自然就显得有些不自量力。不过仔细想想,却也并没有更合适的说辞了,那就索性如此吧。

“写作”这个词可以有很多种阐释。可以说是一门营生的手艺,可以说是一种日常的消遣,也可以说是一种情绪的表达。但无论哪种,作为一种特别的精神活动,写作,尤其是严肃的写作,都是作者和自身的斗争与和解。而在艰难的生活中,人一般不太会想着和自己和解,在轻松的生活中,人一般不会想着和自己斗争。所以有着写作的念头的人,或多或少都有些别扭。

在十几岁的年纪,少年少女大多容易伤感,这种利于创作的客观环境,比较容易生成写作理想。但随着时光流逝,青春不再,理想情怀被生活的柴米油盐挤到一旁,不顺的被压榨得喘不过气,顺利的想着跃升阶级,除了以写作为生,大都早早抛去了什么当作家的念头。

理想不能当饭吃,混口饭吃还是很重要的。毕竟“人不吃饭,就会饿死”。而饭吃饱了,又会想着玩乐,也就是精神生活。写作,当然也是一种精神生活,但在疲惫的日子中,这种精神生活也太过自虐了,远不如出去旅旅游、找俊男美女谈谈恋爱、下点昂贵的馆子,最后再发发朋友圈、晒晒微博小红书以换取艳羡来的容易。

所以在奔三的年纪,不靠写作混饭吃,还一意想着写作的人,着实是有些别扭、甚至是扭曲了。在我的观察看来,这些别扭的人都有些相似,那就是——对痛苦的挖掘和感受能力强,并似乎乐在其中。而作为其中的一份子,我当然也不例外。

我对痛苦的感受能力很强,以至于有时不得不刻意进入一种“隔绝和抽离”的状态,来让这种“天赋”不影响我自己的生活。另一个拥有同类天赋的朋友曾说过——“写作,就是记录痛苦”。我对这个说法深以为然,并补上了一句“而当痛苦不在的时候,我们就会去寻找痛苦”。但细想后却发觉这并非是根源,毕竟就算是自虐,也没有人天生就喜欢自虐。

既然不是天生,那必然就是后天养成的,于是我只能从小时候开始寻思。

对小时的记忆我是比较模糊的,明确的也只有随父母四处奔波,但在这模糊之中却也有些场景相对印象深刻。比如由于ADHD尿床不愿去幼儿园,而被母亲绑在店口的柱子上打;再比如被留守到四川老家,一年见不到两次父母,学英语还被亲戚骂;又比如转学回四川,因为不会方言被欺负,终于在哀求下从四川终于转学回父母身边后,却又因为说方言被欺负;还比如每次考不到全班前三回到家时,都会因为担心被打被骂瑟瑟发抖。

对童年的记忆,虽说不上是痛苦,但大概也可以说是活在恐惧中的。

若对于一个软弱不开窍的孩子,大概就是会自我规训和鲁莽反抗。而我自幼相对聪颖,却又体质薄弱,便自然陷入了一种初始的内耗。虽然成绩不错,却几乎从未得到奖赏,虽然常被规训,却又不服于人,这种聪慧和打压之间的矛盾,让我统一为了扭曲。我不自信,于是敏感,融入群体而不得,便将夸赞视为敌意。有一些反思的意识,但总不能想得通透,有思考又不便与他人言说,可谓是最初的别扭了。而等到了初中,我终于学会了一些遣词造句的时候,便如获至宝,用日记本记下了一些零碎的日常:

我又被老妈打了,就因为一口饭吃的时候久了一点。

这应该是我最早的所谓“记录痛苦”。但痛苦对于一个中二期的男孩子毕竟非常态,更何况当年我确实有些恃才傲物,便逐渐在“记录”之外,将这文字作为了一种“表达”的力量。表达意味着输出,文字便成为一种“将自我的言论刺入他人心中”的投匕,既然是投匕,便自然会遭到正当防卫。而想来我初次遭受这种防卫,应当是在初中。

遥想那日,班主任忽然让我们各自写一个纸条,内容是关于身边同学不守纪律的行为,也就是说,是检举和揭发。而那时的我,显然对“服从性测试”毫无理解,便写了一封小信,以我稚嫩的文笔,大致表述了这样的内容:“让同学们互相检举是不对的,不仅有害于集体凝聚力,还会培养坏的品格”。而结局自然是由我被嘲讽和体罚结束,但班主任也确实没太再追究那些纸条。

遭受了对作为那时绝对权威的班主任的攻击后,我却并未感受到恐惧。虽然现在看来这不过是一个普通的青春期插曲,却也算是一个开端。从这个开端为始,我就在潜意识中构造了一个“框”,努力将自己框入其中,来维持某种“我所应当”。不过有个框很容易,落实到行为将自己框起来却很难,更何况是易变的少年。所以我尝试借助一些外力,这自然就是“文字”——“用文字影响外界对自己的印象,倒逼自己成为一个那样的人”。

从这个角度而言,从那一刻起,我的存在便在追逐着我的文字。而文字又并不能凭空产生,它们必然要有一些养料作为来源,而想起来我的养料最初应当是来自于大家熟知三国、西游,以及周树人。于是能力不足以意识到存在和文字之间关联的我,便误以为我应当真正追求的理想是——“成为英雄”,并凭借我自小的固执坚持了下去。

“成为英雄”是大多男孩子都有过的想法,但在真能挺身而出却寥寥无几,大多也不过成为了自以为厉害的混混。毕竟对权威的恐惧是自小培养的。但不知为何,我从小对这种对权威的知觉却很淡薄,或者说没有概念。虽然不时会被体罚,也确实有过害怕,但在内心中我却从未认为那些被称作“老师”的存在,拥有对我的“管束权”。

这种思想在闭塞的地方着实异类,但好在成绩不错,老师们也没有太刁难,但在大多还是会管教。但到了高中,却发生了一些转机,我进了以素质教育见长的省重点的重点班,有了一个语文老师兼任的班主任。这个班主任比较开放,对我的个性和想法几乎是完全支持,而单纯的我受到这些鼓励后,自然是有了更加激进的思考。于是在一次语文作文中,我以“刘邦和项羽生活在现代”伪命题,褒扬了项羽的直率和磊落,贬斥了刘邦的阴谋和无耻,并得到了老师切实的夸奖——你很有灵气。现在回想起来,这夸赞应该只是惯用的措辞,但就是这样的一些夸奖,让我认为我确实是有责任要去创作的。

这个时候,我尚且还没有将“成为英雄”和“写作”关联起来,而是单纯建立起了极强的表达欲。为了支撑起这种表达欲,在写作之外,我尝试读了一些理论著作,比如一些哲学专著。这些当然都不过是浅浅的了解,甚至一部分是为了装逼,但启蒙的种子也切实种下了。从那时起,我就已经开始对“命名权”、“发声权”、“管束权”这些有了懵懂的印象,并尝试写了一些这样的作文,不过都因为言之无物,而收获寥寥。

再之后,我接触到了二次元,那个年代还完全充满着“真善美”的二次元,出现了一大批亲情、友情、爱情和个人英雄主义的佳作。这些作品也直接将我从现实主义的萌芽,彻底转向到了浪漫主义,并延续了本已漫长的中二期。在这段时间,我也大致定下了一个剧本的初步设定,非常简陋,但满怀着一个少年的真诚。与此同时,我开始寻求“特别”。而后我慢慢得在无意识中,又将这种对“特别”的追求,和“成为英雄”在暗中关联了起来。但毕竟高中还是要应试的,即便是相对的素质教育学校,应试仍然是最后的追求,我不得不应付这些要求,而放弃了一些“自由”的思考。

在这段时间,写作,应当只是一种青春情感的自然抒发,带着一些少年特有的理想主义,在束缚中作着青涩的表达。到了大学的时候,这种种束缚,也就被彻底解放了。

大一的我是混沌和浑噩的。来了一个并不喜欢的工科专业,学着并不喜欢的东西,考了并不如意的GPA,思考也似乎停滞了。但我的表达欲终究还是冲破了这种浑噩,在某次契机之后,在一个学姐的鼓励下,我想起了高中那份青涩的游戏设定,下定了决心。而就在下定决心的那一刻,我的大学也彻底改变了。

我一改之前懒散的作风,开始每天雷打不动得创作剧本。从设定开始、到大纲、到实际的剧情,我花费了大量的心力,甚至现在还能回想起那每天因为在脑中构思剧情,而在路上起的鸡皮疙瘩和由衷的震颤。同时为了增强剧情的理论依据,我还特意从培根读起,沿着霍布斯、休谟、边沁的路径,阅读和抄写原著。

而另一方面,伴随着文字表达欲一齐被唤起的,还有别的创造欲望。我几乎一人申请了一个省级SRTP项目,意图在什么都不懂的情况下,完成一个涉及到FPGA、软件编程、电路设计、机械的体三维显示器,而且一上来就是“120x120LED阵列”。

那时的我没有任何恐惧,也没有觉得有什么是我所做不到的,这也应当是我认为离“传统的英雄”最近的时候吧。作为一个绝对的乐观主义者的我,写出的剧本也都围绕着一个主题——即便经受再多苦难,但人都应当保持乐观的心态,和对未来美好的憧憬。

现在想来那时的我,对“苦难”的认知,确实过于浅薄了。而初次大致觉察到这种浅薄,也是第一次明确有自杀念头的,应当是大三。

大三那年,我选修了一门哲学课,其名为《存在主义哲学研究》。在课上,老师带我们涉猎了克尔凯郭尔、海德格尔、萨特、尼采等等,还列举了一些文学作品。一开始我学艺不精,只是单纯看了一些书作为扩展知识的手段。但出于旺盛的表达欲,我削减了一部分其他的创作,转而去编写这门课的大作业,也就是一篇“哲学论文”。带着朴素的思考,我用洋洋洒洒的五万字完成了这篇名为《重估,虚无,再构》的“论文”。

这本应当是一种当时值得自豪和炫耀的事情,但过程却并非这么顺利。因为作为乐观主义者的我,在写论文和阅读资料的途中,遭遇了“真正的虚无”。

虚无的可怕之处只有遭遇过它的人才明白,也只有真正的遭遇过,才能明确为何“存在主义”的前置是“虚无主义”。遭遇了虚无的我,陷入了无尽的迷惘,意义不存,价值消解,沉重的肉身,残破的躯体,那我为何要活着呢?我已然记不得是如何走出的那段时间,但走出后,我便愈发对克尔凯郭尔产生了敬意,也对真正的“上帝”的存在加以了保留。

在课程之后,真正有些了解了存在主义的我,做出了另一个重要的决策——我购置了《加缪全集》,并全力尝试去读懂它。我竭尽全力尝试读懂这套书,做了很多笔记,其中让我记忆最深刻的便是这一句:

明天,在他本该全身心拒绝明天之时,他还是寄希望于明天。这种肉体的反抗,就是荒谬。

当时我大概是没有完全理解这句话的,但却觉得心中有一种极大而莫名的震颤。而这种震撼在我尝试进一步了解克尔凯郭尔,并看到了他的一句话时更甚:

寻找一个对我而言是真理的真理,寻找一个我愿意为它而活、为它而死的理念。

从那时开始,我便自认成为了一个存在主义者,而我的人生也开始一直伴随着“意义”这个字眼。同时更重要的是我对于“英雄”这个词的见解,也产生了极大的变化。我第一次模糊地意识到,“成为英雄”需要有代价,这对于个人而言可能并非是什么好事。但另一个角度而言,正因为如此,“成为英雄”的想法才是真正可贵的。从这一刻起,我便将“英雄”和“意义”挂钩了。然后很快,在阅读了加缪《写作的光荣》一文后,我便又将“英雄”和“作家”关联了起来:

为真理服务,为自由服务,这两条也足以体现作家职业的伟大。既然作家的使命是团结尽可能多的人,那就只有容忍谎言和奴性。这个世界充斥着谎言和奴性,孤独的荒草到处疯长。无论我们每个人有怎样的弱点,作家职业的高贵永远植根在两种艰难的介入中:拒绝谎言,反抗逼迫。

也因此,“写作”和“意义”便第一次被我紧密得关联了起来。

但学生毕竟是学生,象牙塔里的思索终究还是太过浅薄。即便我带着后续存在主义的思考,尽力去完成了七十万字的剧本,但它终究没有达到我已然进化的审美。于是我便将其暂时搁置,而是集中于另外的一件创造,也就是代码,或者说,是“开源”。要问在过去的十年内,对于我而言,有什么的重要性是可以和“写作”相比的,那必然是“开源”了。

写作,是是表达自己认为正确的理念,来唤醒众人;开源,则是无偿奉献自己的知识,来帮助众人。

当然,我并非是从一开始就将“开源”想的如此无私,大致只是想获得更多认同罢了,后续也了解到了“开源”成为了许多公司运作项目的手段。但自从看过了《互联网之子》这部纪录片后,对于开源这件事,我“无偿奉献”部分的比例确实大幅增加了。在做项目之外,我也尝试起了“技术写作”,将攻克项目的心得落成文章记录下来,在进一步分享知识的同时,也保证自己真的搞懂了它们。

在做开源和输出技术文章的时候,仍然是我的表达欲占了上风。我将SRTP项目、毕设项目等都开源并写成了文章,毕业论文一开始也写了五万字。这对于技术充沛的精力、无尽的热情让我对文学方面有些怠慢了,那时候的我已经在技术上得到了表达的满足,不再痛苦,也不需要通过文字成为英雄。尽管如此,我还是在学历档案最后的自评中,洋洋洒洒写出了如下的留言:

挫折并不足惧,只怕丧失灵魂。

毕业,旅行,入职华为,离职,去上海。短短几个月,我的生活经历便超越了象牙塔中四年的总和。不过由于从事技术工作,充满热爱的我确实也比较顺利,学到了很多知识,产出了一些开源项目,也获得了一些尊重。但这种和人不断的交际却不时产生疏离的孤独感,我总是感觉自己忘记了什么。虽然还在按部就班得“重写”着那个剧本,但写了十万字后便也搁置了。我开始有一种不详的预感,于是在刚毕业不到一年的时候,我以“创作训练”为由,接连写出了几篇短篇小说。

在完成它们后,我也终于明白了自己到底在担忧什么。被我忘了很久的那个“框”,终于再次出现了。只不过在漫长的岁月中,它改变了很多,将我框得更紧了,所以才会让我感到窒息,所以才会让我创作出这几部作品。在它们之中,《【短篇小说】寒苍-晗樱-S1-α》《【短篇小说】寒苍-晗樱-S1-β》这一部是最为出色的,也是我认为至今都未超越的——并非在于它的技巧,而是在于它承载了我所鄙夷的一切,却似乎在往后的日子里逐渐成为了对我人生的预言。在另一童话作品的最后,我也借着年少时的我的幻象,不错,也就是后面频频出现的少年H,表达了对我自身的告诫:

“再多说也已无益,你既然来此,就证明你正在改变自己。或许是为了生存,也或许是为了某个所爱的人,又或者,是为了通完梦想的迂回之路。无论我说什么,你还是会继续改变下去,但即便如此,我还是有所期望。虽然我只是你的影子,但也是你永远无法摆脱的影子,当你偏离我的期望之时,我会永远在你耳边叨扰,撕扯你内心中最柔软的那一部分。这样下去,总有一天,你终究会在某个高楼之顶或是大海之滨结束自己吧。所以,小心点,毕竟H他也离职许久了,那时候,可没有人来拯救你。”
你就跟着这趟列车,坐在那最末的位置,回到你所厌恶又不得不赞美的世界吧。

现在想来,这应该是我潜意识中一种自发的警示,一种让我不要忘记初心的警示。作为一个普通家庭出身的孩子,为了让理想不成为空谈,我必须要进行迂回,但人又往往会在迂回中忘却初心。我便只能将自己的生活当做戏剧去出演,又时刻编写预言警示自己。不错,有个框很容易,但将自己框起来却很难,所以必须借助一些外力。性格内向的我,年少时尚可由老师同学作为外力,成年后自然就只能由自己来。故而我只能虚构出一个个分身,赋予他们生命,从而对自己加以约束。而虚构分身,本质上就是写作。从这个角度来讲,自那时起,写作,也就成为了这个框的副作用。

这创作训练终究也为生存停让步,我谈了恋爱,换了工作,考虑未来,房子车子等压力不断袭来。进入阿里后,我的工作压力越来越大,也不得不将大部分时间投入进去。借之前打下的底子和良好学习能力的福,我也换来了丰厚的回报,快速的晋升,四五倍的涨薪,让我也有些迷失。但看似顺风顺水的我却总是在某些深夜刺痛,那个“框”就像是幽灵一般,不断在我耳边叨扰,于是每年新春和生日的文章就这么生成了,也就是那些看似“矫情”、实则本就是写给我自己的作品。

在现实中,我似乎越来越“成功”;在作品中,我却越来越“失败”。

这种状况持续了两三年,终于在前年被打破了。生活和工作的双重动荡让我疲惫不堪,却意外让我获得了所谓“成为英雄”的机会,而我也确实选择了成为我所认为的“英雄”。但现在回想起来,这事件本身并不重要,重要的是我终于发觉了我“还有创作的可能”。换到了新的环境后,工作压力降低了不少,我重新读起了许久未打开的严肃文学,读起了哲学,看起了话剧、展览和文艺片,并真的尝试继续写作。

然而早已丢掉的技艺又怎能轻易找回?我绝望得发现这几年我的文笔看似进步、实则倒退了不少。在这种打击下,我将自己藏在了一个挡箭牌后——“只要我不开始,就不会失败,现在还不是时候,只是需要更多积累”。显然,这不过是自欺欺人,但在文学出身的EX的劝谏下,我又想起了加缪的观点:

一个严肃的创作者最重要的不是技巧,而是真诚。

因为技巧可以通过花时间不断磨练得到显著提升,同时年龄带来的阅历也会让其更加丰富,但真诚却容易随着年龄的推进而逐步丧失,同时可能丧失的还有那种敏锐和纯粹,而我需要尽量避免丧失这些东西。明确了这一切后,我切实得又开始创作了。一开始确实不如从前,但却也在不断学习和进步。非虚构写作课程,虚构写作课程,故事,大师写作教程。为了创作出有价值的东西,我一边吸纳着这些理论,也同时在当前的阅历下,不断进行着反思。

这反思自然有多个角度。不过既然是源于写作的反思,那么反思也必然由写作开始。我首先考虑到的是“写作”的对于我的真正内涵,我曾将它和“英雄”、和“价值”、和“意义”等关联起来,但这毕竟都只是少年青涩的想法。虽然是真诚,但也同样稚嫩。而在成长后的现在,我大致能给它终于定个性了——

人到了一定年龄,都会寻求一种终极价值感。对于我而言,写作,就是为了实现这样的而一种价值。它是承载我生命的厚度、面对这个荒诞而虚无的世界的唯一方式。

承载,是指“记录”;面对,则是“表达”。拥有了这两个功能,“写作”才算是真的完整。在很小的时候,我只会记录,不会表达,于是作品中少了一些情感的色彩;而成长途中,我又只重表达,放下了记录,作品便失去了厚度,为浓烈的情绪所淹没;再到后来,我尝试去掉这情绪,却又没有能够忠实地记录,就显得过于克制和概念化,而成了一种审视的态度。

无法解决这些问题的我,便进入了创作的瓶颈,于是只能从阅读中寻求答案。但在相当的一段时间内,阅读并没有带给我答案,而是让我更加痛苦。严肃的作品带来严肃的思考,严肃的思考带来对苦难的理解,理解了苦难,就自然难以幸福。同时作为一个写作者,这痛苦也并非完全来自作品的情节和思想本身,还有一种能力上的痛苦,即——我很可能永远也写不出这样的作品。这双重痛苦不断折磨着我的精神,即便是偶尔产生的那么一丁点优越感,比起这痛苦也不值一提。

有段时间我陷在这痛苦中,迟迟难以前进,找不到问题所在。我蜷缩在自己的房间内,不和人交流,也不出去生活,只是在房间内思考、阅读、阅读、思考。思考的东西也无外乎一点——我总以为自己有一种为作家而生的宿命感,为何却写不出东西?

这种宿命感并非无稽之谈。虽然并非刻意追求,但我在我的认知中,我的人生总会出一些状况外的小概率事件,却又能被一些不可抗力推着解决。高中以为考砸却正好进了一个文艺好学志趣相投的宿舍,花了三年学习FPGA破格进入的第一份工作却在一个月就被放弃,进入小硬件创业公司却恰好遇到十五年经验老程序员带上路转行,B站第一次拿了低绩效准备摆烂却立马个赏识自己给自由的老板,因为变质离职跳槽掌心不多却立马得知B站上市错过大幅调薪,遇到好老板尽力刚升小P7准备一展宏图却很快卷入政治斗争的一部分,生活要极大转变的阶段时却迎来了疫情,带着气势认为平薪跳槽却在东家却在离职前一天宣布上市,在不时的悔意中过了几个月后又宣布上市中止见到了无数的梦碎,好不容易缓下猫患病濒死却又奇迹般抢救了回来,之后攒够首付又面临一波暴涨和政策调控,等等等等。

但思前想后,内耗再深,写不出东西还是写不出。直到某一天,我怀疑起了这思考的意义,便终于决定不再想,而是尝试走出了这房间。我不去思考,而是生活,在认识了更多的人、了解了更多的事后,却反而意外明白了症结所在。这个症结很简单,也是对于写作的最原始的问题——

你想记录什么?读者是谁?又想表达给读者什么?

从前的我可能会说是“为边缘人群发声”、“为了规训大众的错误”,但从结果来看,我不过是在“记录自我的挣扎,表达一种告诫”罢了。也就是说,过去我本质上写的都是“我自己”。无论有多少角色,有多少种风格,都不过是我自己。但过去我认为这毫无问题,写自己当然是没有问题的,毕竟有很多作家都是在写自己。然而在和不少能理解我、文学圈子的朋友沟通后,她们站在一个客观的视角,以尽量克制的态度,向我表达了见解——

你小时候确实不太顺利,出身不行,父母关爱少。但后面家境也不算差,从高中开始、大学乃至工作后,即便你认为自己考砸了、工作也很拼命,但获得回报相对大多数人还算是很顺利。即便后来有一些很不好的事情,但出于你的能力和运气,最终的结果也没有太坏。童年确实有许多可写的,但这些的对你现在的思维水平而言,又不太能被看得上。而你真正看得上的题材,不仅离你现在的生活太远了,并且也局限太多,连个违背点伦理的东西都不愿写。而最为致命的则是——你过去向来都只能看到“自己”,而看不到“他人”。当你越来越强,便越来越难以将你想论述的苦难带入自身,余下的便都是一些不接地气的“求而不得”。

对于这见解,我也终于承认了。之所以说是“承认”而不是“接受”,是因为这些想法其实早就在我的心中扎根,只不过一直在被压抑罢了。毕竟这会触及到我“自怜”的根基,如果我自己并非苦难的,那我的痛苦又有什么意义?为了并不能称为真正苦难的经历而痛苦,不就是一种单纯的无能吗?王小波所言“人的一切痛苦都,都是对源于自己无能的愤怒”不就是在说我自己吗?

但我确实是无能的。在这几年的经历下,我终于明白了有太多事是我无法做到的。过去的人生中,我一直在试图追求一种完美,不仅是写作,也是在工作上。但最后别说完美,很多项目和作品连完成都做不到。这不断烂尾的结果让我很痛苦,但却也带来了不少教训,这个教训就是“完成很多时候比完美重要”。在得到这个教训的过程中,我一次又一次得体验着“求而不得”,于是这“求而不得”便成为了我主要的痛苦,也是我认为的苦难。

我的苦难,来自于我的无能。无能造成苦难没有什么问题,求而不得也没有什么问题,事实上相当多动人心魄的作品就是在描述这两个命题。但我的问题在于,我求而不得的,并非生离死别,也并非真正的没有期望,而仅仅是“我无法比别人更快地完成”。而想要比他人更快,最早应该出于从小母亲的教导,也就是一种“要成为人上人”的想法。在懵懂的时期,我使用高洁的志向将其包装了起来,于是产生了一种无法兼济天下的痛苦,虽可以说是虚伪,但却也确实真诚,这也就是为何我认为之前的文章仍然动人。但在清醒了之后,若还抱着这样的想法,那就是纯粹的自欺欺人,沽名钓誉了。

我当然不想成为一个沽名钓誉的人,即便是在懵懂的时候,高洁的包装也不允许我如此。不过如若单纯撕开了包装,仅留下赤裸裸的无能,却又很容易再次滑入虚无。如果再次陷入虚无,那记录和表达也就失去了意义。既然失去了意义,那么言说带来的抨击便不能再被正当地承受,而带来更多的伤害。于是我只得再次去审视那个包装,审视着它,我又不禁想到了《堂吉诃德》。

第一次接触《堂吉诃德》是比较小的时候,那时的我并不能够看懂这部作品,荒诞不经的情节和喜剧的效果逗得我哈哈大笑,只觉得主角是个傻子,是个疯子。但当时隔多年后我在上海看了同名的话剧后,却并在现场潸然泪下,也终于懂得了作品的内涵,明白了主角的高贵之处——疯的不是他,而是这个世界。因为世界上往往是容不下理想主义者的,光辉的人性在现实面前反而会成为绊脚石,而我也曾因表现出的理想主义外框被抨击谩骂过。

所以最终我发现自己不但不厌恶这个包装,反而还非常喜欢。即便曾经我只是躲在这个包装下的伪物,在漫长的惯性后,也还是想尽可能努力将其化为真实。从这个角度来讲,我似乎绕了一圈,又回到了最初的矫饰,但实则并非如此。事物是螺旋上升的,每一次自我否定的痛苦和折磨,应当都是为了下一次的重生。诚然面对许多事情我是无能的,也确实越来越缺乏真正苦难的经历,但对于书中和新闻中的人和事,也能够从更深刻的角度去共情和反思了。

想通了后,我便依然尝试走出“自我”,去看到“他人”。抱着这种想法我开始了第一次实践,这也正是我最近创作的那部名为《Project Tomorrow》的剧本。在这部剧本中,“自我”的比例仍然不低,但在角色塑造上也参考了圈内朋友们提供的个人经历、观察素材,还接纳了不少的写作建议。这其中的每一步对我而言都是不小的障碍,毕竟等同于打破过去十几年一直处在的创作舒适区,但我还是尽力这么去做了。因为我明白,倘若不去这么做,写作这条路基本就到此为止了。

一开始这样做的时候,我仍然在担心失去自我。毕竟对自我的剖析确实算是一种天赋,而我又本就技艺不精。事实上当我尝试抛去主观的滤镜,写作本身确实变得困难了许多,这带来了一段时间的创作焦虑。但在逐渐尝试克服了这一切小有成效之时,我却感受到了前所未有的成就感,这是我第一次在开智后由衷觉得“我或许能行!”与此同时,我的心境上也开阔了不少,创作的精神压力也小了一些。

创作的精神压力减小,对于我而言可能比技艺提升更为重要。从前我认为作家必须要献祭自我,去将献祭的过程描绘出来,达成真正的艺术。也即作家首先本身就要有苦难的经历和性格缺陷,然后在“一方面需要寻求疗愈,一方面又要加强这种感受”的挣扎下表达出最强烈深刻的情绪,并对福克纳的那句表述深以为然:

一个人无非是其不幸的总和。

要描述自己的不幸,就要制造自己的不幸,这严重影响了我的心理健康,更可况我的心理本就有创伤。但在我真的制造了一些不幸、体会了一些不幸、感受了除自身外的更多的不幸后,却也更能理解这句话的后续:

有朝一日你觉得不幸会感到厌倦,然而自此以后,时间却是你的不幸。

正如我对自怜、自嘲的重复感到腻歪,我对某些重复的不幸也感到了厌倦,或者说,不幸自身也感到有些厌倦了。如此一来,我便不能再靠制造自以为的不幸来进行记录和表达,倘若要继续创作,就必须寻找新的根基,这又要提起那个根本的问题——

你想记录什么?读者是谁?又想表达给读者什么?

过去,我将作品禁锢在“自我”中,却又在寻求外界的认同,期望得到同类的理解来让自己好受一些。而这同类确实太少,并随着不断得成长越来越少。所以我的读者越来越少,也越来越孤独,最后陷入了不断的自我否定,而越是否定,创作就越电波,读者就更少。诚然最终留下的读者都是值得珍惜的朋友,但这真的是我想要的吗?

对于现在的我,这个答案很明确了。我并不想否认过去的自己,也并不认为过去走的是弯路。没有这样的过去,就没有现在的自己,也不会有这样的坚持。然而正如一年有四季,人生也总会有分界线,而十四岁十四年后的二十八岁,对于我也正有特别的含义。当然,在分界线后,如何处理自己的过去是个严肃的问题。有人会将过去的情怀化为戏谑,来抵抗现实的失意,但我认为接纳才是长久的选择,也算是一种扬弃。

所以对于这个问题,过去的我给出了过去答案,现在的我也要给出现在的答案,那就是“记录为我认为值得的人和事”。在相当一段时间内,我认为这些人是“边缘群体”,但这确实有些狭隘,目前想来,应当将其扩大为“沉默的大多数”,也就是的“历史的承受者”。不过其实没什么不同,在网上“大多数”反而是真正的“边缘群体”,哪怕只是记录其中的一两个侧面的切片,我也知足了。

不过描写“努力的大多数”,显然比描写“边缘群体”来得高风险。不能说的风险先不提,身处容易摇摆的所谓“中间”,要破除那种傲慢与偏见,要在对方明显冒犯我时隐忍,要控制面对无知时下意识的情绪。但转眼一想,大多数还是比那种内心阴暗、却道貌岸然的人好得多,毕竟没那么多花花肠子,也不太会伪装。我选择做技术,就是为了环境和心灵的相对纯粹,而相对纯粹的心灵,也正是严肃创作的根基之一。

当然,这也不过是一个未来的期望,毕竟这对我的能力提出了更高的全方位要求。“把握真相”的难度,“表达观点”的风险,难以让大众接受的“陈述理念”,容易败絮其中的“论述故事”。对于创作者而言,表达越多本就越容易让人误解,更何况是要将理念融入到通俗易懂的故事中,现在的我能力是远不能及的。但写作毕竟还是一门技艺,不锻炼永远成长不了,所以我还是会不断得表达,即便不是为了技艺本身,思想自古也是在言说和碰撞中不断演进的,正如尼采所言:

一切被压抑的真理都将变成毒药。

总之,在漫长的成长后,我终于认清了自己没有天赋的事实,明白了这条路的尽头可能只是一片虚妄,也确信了自己只能成为一个努力型的创作者。严肃的写作最重要的就是不功利、把握内核的信念,走出小圈子、关注社会的洞察力,以及不被挫败感击跨、循序渐进的恒心。从一开始想写出惊世骇俗的作品,到现在只想做到能让一部分人感同身受、并且帮到他们,我确实也转变和成熟了不少。这可以认为是一种由于能力不足产生的妥协,但也可以说是找到了精准的定位,目标更加明朗了。

而在此之上,我最需要警惕的,仍然是从很久前一直警惕到现在的,对27岁生日文章《青年H,二十七岁,一切如常》中这段反讽和自嘲的背离——

“她的坠落和我又有什么关系呢?反正这也不过是一个虚幻的场景,虽然原理不明,但原路返回应该就能回去吧,尽快脱离这种矫情的状态就OK了。还是早点回去睡觉比较重要,毕竟第二天还要好好上班。对了,最好是在睡前再看看新的设计,保证能够比较妥当地完成业务。好好努力工作,多拿点年终奖,这样就能一年首付,三年还完房贷,五年成为艺术家了。啊,是啊,多么充实的未来,多么美好的许诺,只要我一遍又一遍叙述着这个故事、一遍又一遍升华这个故事、仿佛它已经完成了一般,我就能获得无数的资源、无数的尊重,我就能成功!”

不忘记初心,暂且放慢脚步,以不同于以往的那种急躁去相对平静得生活和观察。如此坚持,三年、五年、十年,只要不放弃,在不自杀的前提下,我总有一天能写出无愧于心的作品吧。

当然,这一切中最重要的前提是能够抛开傲慢与偏见的滤镜,来真诚客观地观察这个世界。过去有一个跟了我八年的个人签名是“自有地看待世界,真诚地看待自己”。而我现在觉得,“真诚地看待世界”也同样重要,甚至说更加重要。

那么回到最初的问题:“我为什么写作?作为一个没有才能的人,我所真正期望的,到底是什么?”

我现在能给出的答案是——

记录我所能及的现实,表达我所信仰的真理。证明我所坚持的理想和信念,以及那一切的代价,并非毫无意义。

回顾2021

作者 dtysky
2022年2月1日 08:00

若按大学毕业以来的惯例,这个新年我应当又化身“青年H”从房间出发,与分身及某些臆想的朋友再来一次旅程,但想来这不过又是一次“重复”。这重复既已经进行过七八次,我哪怕再能够容忍,也终究有些腻歪了。

在完成庆生28岁的《Double;14》前,我就决定,这将是几年内最后一部参杂着“自怜”、“自嘲”、“自保”以及对自我的“警醒”和“规训”的作品。从此我会逐渐走出只有“我”的命题,去做更多尝试,故而“青年H”将会消失几年。

所以这篇文章可视作是这一年的总结,也可视作一次对过去的扬弃。

事业

在事业上,我基本完全脱离了传统前端,转向了游戏和图形方向,并结束了自B站以来连续四年的全优绩效,第一次拿了三星。我并没有想象中的那么失落,反倒是轻松了很多,这大抵是因为认清了对很多事的无能为力。虽然作为一个技术从业者,我仍然会尽力完成工作目标,却也不再那么执着于绩效和晋升了。

过去一年我主要完成的,就是围绕微信小游戏框架所做的一系列工作,这个框架主要是为了在小游戏环境内提供毕竟原生体验的性能。我的工作主要包括:

  1. 参与了渲染层的重构设计、RHI层的前端,以及可编程渲染系统的设计和实现。
  2. 整个WebGL后端的实现(包括节点系统和动画部分),同时为未来的WebGPU后端实现留好了口子。
  3. 其他的一些跨端功能。

目前引擎已经支持了《新轩辕传奇》、《天龙八部荣耀版》等中大型MMO上线。

其他的工作和引擎关系不大,都是一些探索性的研究吧:

  1. 构建了一套框架,为了方便实现中小型休闲游戏的内容制作。
  2. 接入网络同步,设计便于使用的同步方案。
  3. 尝试全新的交互方式。

从大二开始算,回想编程这九年,我从FPGA开始,历经嵌入式、前端后台、互动、游戏引擎,到现在的Gameplay,整体适应能力还行,也说明我确实还挺合适这行吧。与此同时,我也逐渐认清了卷是没有尽头的,大概率并不能靠一个技术吃一辈子。了解了很多事情的本质后,也明白了生活和工作平衡的重要性。所以也完全褪去了当年在阿里的那种卷王惯性,慢了下来,非特别紧急的状况几乎不再加班,拥有了更多自己的时间。

也确实感受到了互联网寒冬的到来,真实了解到了身边各个公司朋友被裁的惨烈。想来从蚂蚁中止IPO开始,不少同行的人上人梦破灭了(是的,蚂蚁欠我的那点期权估计也无了)。不过也好,就借此机会想清楚什么才是真正重要的事情吧。

不过好在我对技术的热情并未削减太多,也佐证了技术对于我确实是处于“兴趣”而非单纯“赚钱”的领域中。不过这也是必然的,作为一个INFP,不热爱的东西我根本无法坚持。

技术

去年一年在技术方面(特指工作之外),我比往年投入确实少了一些,这可能是由于生活和理想占据了大量时间。不过即便如此,我还是完成了《WebGPU Renderer & Path Tracer》:

""

  1. 一个基于WebGPU的渲染器,并用其实现了一套实时光线追踪管线:webgpu-renderer
  2. 基于整个项目的原理编写了系列教程,共八篇:《WebGPU实时光追美少女》系列

整个系列我主要参考了闫神的GAMES101和GAMES202,还有一些其他的教程。我对这种无偿分享的精神表示感谢和敬佩,自己也只能尽量无偿分享自己的知识来帮助到需要的人。

除此之外,个人感觉也有一定从技术至上向技术实用主义的转变,思考的维度越来越不是“这个技术好牛逼啊我要学”而是“我要完成一件事应该学习什么技术”,某种意义上也算是对技术祛魅了吧。往年的我总是要求自己每年学习一门新语言或者领域的技术,来证明自己对技术领域的不弱于人的完全兼容性,但现在看来也没太大必要了。不再被这种胜负欲的执念所约束,才有更多的精力完成目前阶段更重要的事情

理想

目前的我仍然是一个理想主义者,即便明白了这种特质是生存的障碍,经历了很多求而不得,却还是没办法选择及时行乐。不过相对得随着成长,也越发觉得拥有理想和追求是一种幸运,是一种对抗虚无的最终武器。相比以前,我成为了一个比以前更坚定和务实的理想主义者,也就是完成了相当部分INFP->INFJ的转向。过去的我十分容易被抨击中伤,毕竟纯粹的理想主义者大都善良细腻和敏感,容易按照自己的高标准去揣测他人,便更容易受伤。但现在的我有了明显转变,不再把所有问题往自己身上揽,而是能客观看待社会上的种种恶意,不错,我现在的观点是——如果你认为一个全力靠自己追求理想的人有问题,那错的必然不是他,而是你。就算失败了也是自己的事,并没有任何人拥有贬低他抬高自己的资格,在这里说这些,主要是去年某位大佬去世在网上和某些言论对线血压飙升,实在是不吐不快。

相对于前几年,去年我在理想方面的推进有所突破,毕竟离大学毕业时设下的第一个Deadline(三十岁生日前完成一部质量可以被自己认可的独立游戏),只有不到两年了。而在这喜人的另一面是恐惧——虽然信念大方向仍然是一致的,但我的鉴赏能力却着实高了不少,这带来了严重的“眼高手低”的效应,从而产生了一种极强的“求而不得”,如果是创作者的话,应该明白各中痛苦,但反言之也确实是一种动力。

首先是一个插曲,即我对于画画的尝试和暂时的放弃。为了评估自己在艺术方面的才能、以及拥有基本的造型能力,我报了一个半年的画画班,完成了从素描到水彩的课程,具体的成果可见报班学素描和水彩半年成果。在这半年内,我基本保持着工作日每日两小时/周末八小时的训练,也确实取得了一些进步。但由于精力和天赋方面的考量,我最终还是将其搁置了,认清了自己在这个领域只能靠努力,却又不具备努力的时间和精力,客观条件不允许,这也是没办法。

其次是为了试水,我完成了开头说的生日游戏《Double;14》。其基本代表了那段时间我内在的真实挣扎——对铁quan的无奈/一时间明白了很多社会问题的茫然/父母和家庭的矛盾/猫再次作死手术/闹分手的末期/对自己成长的抗拒/对之前一个阶段成就而沾沾自喜的自己的反思和鄙夷/不想再失去任何东西的内在动机。个人对这次通过照片和拼凑素材、并在一个半月内完成的作品还是相对满意的,达到了预期效果的80%吧。

在这个作品和正式开始游戏创作的间隙,我在朋友介绍下报了南方周末的《非虚构写作课程》,学到了不少知识和见解,也认识了TOP新闻系出身的、理想主义同类好友R。在课后,我完成了从未尝试过的非虚构写作作业,其中花三个夜晚完成的《在蚂蚁IPO前夕离职的天宇》一篇获得了老师良好的评价。当然,若以现在的眼光看回去,从非虚构写作的角度确实还可以写的克制一些,我也努力将这种经验带到了接下来的创作中。

接下来就是一些下面要提到的生活上的事了,然后是理想中计划的独立游戏的设计。

事实上这个构思很早就开始了,但又被我一次又一次推翻。最早的是源于高中、写于大学的70W字剧本《梦见星空之诗》的重构版本,这是一个纯粹的校园青春梦想外壳下、谈谈存在问题的GalGame,但由于后续阅历增加,导致其越来越不太符合逐渐发展的审美,再加上由于卷事业而搁置。再次是前年早期写的一个计划约30W字的企划《Project Heart》,这是一个相关于精神疾病的边缘群体互救以及自救的故事。我花了大概一个月完成了人物设定和初步大纲,但后续发生了很多生活和工作变动(阿里政治斗争、跳槽微信、换城市搬家等),加上确实是能力不足,所以也暂且搁置了。

在这两次失败后,我确实对自身的能力产生了很大的怀疑。这“毫无天赋”带来的深切的绝望,曾在众多夜晚深深地折磨着我,甚至数次将“自杀”的想法灌入脑中。但好在EX(TOP文学和心理学出身,高级心理咨询师,也是理想主义者)的“创作最重要的是坚持,而不是你总是怀疑的有没有天赋,有天赋的人多了去了,真写出来东西的有几个”言语鼓励下,我在清明节前后开始了新的构思,企划名为《Project Tomorrow》。这次借鉴了之前的经验,重点考虑“可行性”,所以我大致构思了一个情节,并计划将剧本限定在三万字左右,但又由于后续生活和工作中的种种事情搁置了。

再后来已然是再次缓和心态的九月底了。在好友R的鼓励和讨论下,我正式开始了剧本构思的编写,并在十一期间完成了初版大纲,并定下了一个阶段性的Deadline,同时最终在春节前完成了80%,虽然远超三万字,预计大概有九万字:

""

不过这只是第一版,在EX曾经的建议下,我第一遍应当先写下所有想写的东西,然后再在第二遍、第三遍去修改和精简,它将会成为一个可能精彩和具有人文关怀的故事(对于我而言,人文关怀是最重要的,其次才是精彩,但这次我会试图把握好这个度)。除此之外,我也在重点学习让更多人能接受的表达方式,也就是对文字的再次转向和合理运用——描写除了自身之外真实客观的情节,尝试去掉相当长一段时间的审视的滤镜,当然不是说完全改掉,而是因地制宜。当然有一点我是不会改的,我一向讨厌宏大叙事,更关注的个体的存在问题。

这么看来,另一个角度而言,我也抛去了一些创作上的固执,也愿意接受他人的建议和帮助了,这对于我而言非常艰难,但终究还是走出了这一步。这里尤其感谢写作圈子朋友们的建议,以及其他领域朋友提供的素材

在编写剧本的同时,我并行参加了南方周末的《虚构写作课程》,又学到了很多新的见解,人性和文学性的把握、如何真正读懂作品、故事创作技巧等等,也终于第一次真正尝试尽量走出写“我”的禁锢,定了一个我非常不擅长的题材,耗时十晚完成了大作业《灵魂探测员》。写自己完全不擅长的东西是很痛苦,成果非常不尽人意,等后续有空重写吧,不过也确实也有一些收获吧。当然,既然没有天赋,可能终其一生也无法成为一个三流作家,短期速成也不过是虚妄,只能说知道了一些科学的写作方法。

在这些之外,为了情节,我还可以去做了各种取材,比如去了深圳线下的八十人相亲会(同时感谢好友R舍命陪君子、扮演成女主的样子和性格给出真实感想),再比如独自一人在晚上去了海边感受那种清醒的绝望,比如:

不一会,我便到了公园,由于是傍晚,这里只有少许的游客和散步的居民。我走了一会,找了个椅子坐下,静静看着前方草坪上的一家三口。我望着他们,试图回想过去人生中和父母一起外出的场景,但却不太能回想出来。在草坪上的野餐,孩子的开心,母亲的慈祥,父亲温柔的眼神。可能是由于疲劳,我感到了一瞬间的困顿,但又转而觉得理所当然,于是便不再去想。现在,我只想开心一些。
椅子旁边有棵树,两三只鸟儿在上面跳来跳去,我在下面望着,似乎走了神,等回过神来已然过去了半个小时。天色渐沉,凉意渐甚,我感受到了些许饥饿,却并不想吃任何东西。
时间差不多了,差不多该走了。

总之,目前而言,我越来越在意的是“按时完成”,而非“达到完美”,为了不妥协一些核心的东西,而在其他方面做出一些妥协,也算是一种等价交换吧。回想起大学时毕业的我,满脑子想的是“赚够三十万,就去全力做游戏,做出来了就自杀”,而真的赚到的时候却也都没有真的这么做(要不然估计现在都不在人世了,笑)。现在的我虽早已明白当一个“理想主义者”大概率没什么好下场,也在不断暗示自己做好心理准备,但也无暇思考太多,毕竟还有一堆已经起好名字的项目在等着完成。

生活

比起前面几项,生活方面发生的事情有点多。前年一年发生的改变超过了过去的总和,去年的改变则是比前年更甚。这些事件中有值得自豪的正确决策,也有付出代价的错误决策,从而也让我真切得从我“自身”的角度,体会到了人的复杂。在这些经历导致的内耗和疲惫之下,我几乎是每隔一段时间就会质问自己——“你所真正期望的,到底是什么?”我一度认为这个问题的答案是“成为英雄”,但细想来却仍旧没有一个真正满意的解答。于是每次都是作罢,去做些力所能及的事情,反倒却轻快了不少。

说起来到广州也一年多了,最大的感触是自己确实比较适合这边的气候。寒冷的时候不多,空气也比较好,慢性鼻咽炎的状况比在杭州时好了许多。吃穿住行算舒适吧,就是文化活动和魔都还是不太比得了,但过日子确实还可以。刚满28的时候,我落了户,拥有了购房资格,并靠自己攒够了首付,但这时却发现房价近一年又涨了一大波。正在我犹豫要不要上车的时候,政策相继而来——利率的暴涨,指导价出台,房产税的预定等等,在和同事与朋友的讨论下,最终我暂时放弃了购房的想法。既然从增值的角度已然不可保证,那顶着利息购房也已没有意义,当然这个见仁见智。不过尤其吐槽下广州的均价陷阱,基本你能看上的地方也都不便宜了,大把便宜的(总价500万之内)一般都是各种硬伤。

简单总结——躺平了,也借此反思了更多,由于之前的工作和规划一直比较顺利,我一直有一种“特定时间成就”的执念。而终于当放弃了“28前靠自己一线城市购房”的规划后,终于逐步走出了食利阶层定下的社会规训,不想再进行什么世俗上紧迫的规划了,而相对的会把更多的时间分配到理想和生活上。说是明白了自己的无能也行吧,反正一直都自认失败人士。当然,另一面我也明白了反正钱都会贬值,也经历过节省攒钱后的付之一炬,现在比较注重生活品质和体验了,开心就好。

再次拒绝了家里资助首付,从15年在南京全款买房(同时也错过了上车最后时点),到给我托关系安排银行工作,我都是全然拒绝。后来压力很大的时候也不是没有犹豫过,但最后也都出于各种考虑选择了拒绝。究其原因:“吃人嘴短,拿人手软”。尤其是见过不少家境好的朋友难以脱离原生家庭,在家人控制和自我实现中挣扎的那种痛苦后,我更加觉得“相对自由的人生决策权”比那一时的压力重要得多。与此同时值得讽刺的是关系的颠倒,寻求认同的双方尽在漫长的岁月后调换了位置,但既然当初你们没有给过我认同,又何故来寻求我的认同?

然后是和EX的分手,前面也说了对方很优秀,但双方性格确实有不少冲突,两人虽然同样理想主义、同样具备实现的能力、同样强的赚钱能力、同样喜欢文学、同样了解真正的苦难、同样层次的圈子,但也同样缺爱、同样没有安全感、同样渴望关注、同样惧怕失去自我、同样为自我实现焦虑,加之我对她“拥有强于我的文学才能却最终选择了浪费”的耿耿于怀,都成为了潜在的分手因素。虽然后续性格磨合了不少,也让对方理解了优秀游戏的艺术性和文学性,但和一开始说的不同的连完整周末都不能保证的长期异地的预期、性格和习惯导致的不断争吵、加上那段时间的综合极高压力又加剧了这些冲突,最终还是主动分手了。自此以后,自己订了一个自认为比较稳妥的标准(从圈内女性朋友的角度并不高,只是精神层面可能高了点),然后慢慢尝试习惯孤独,宁缺毋滥吧,比较佛系,后面发生了一些事,让我不得不积极一下,也是后面抨击的主要来源。

相处期间,EX提出过可以养我让我全力创作,她也养得起,我也说过她当作家我也能养的起。但真当可能的机会摆在面前时,双方都发现无法完全依靠他人,尽可能不欠人情,执意选择HARD模式,在这个时代可能也算一种性格缺陷吧。

在分手的前两三个月,我家的大猫滚滚忽然食欲不振,后来经过了一次误诊和不断折腾,花了三万多以及大量的精力,终于将其救了回来,详见爱猫误诊、手术、病危抢救一个月经过。但讽刺的是在从鬼门关回来后不久,他又乱吃东西,最后在我因为生日游戏、工作压力、吵架等同时的压力焦头烂额的时候,及时发现送去了医院又动了一次手术,耗钱耗时。回来为了防止再犯还大半夜在阳台装了个大笼子,期间划伤了手以及被天花板掉下的钢杆砸了。那时候确实又脆弱又焦虑,女朋友却不能在身边(已经换了工作),之后设想其实她出事了其实我也不能在身边,也会争吵,并且这种状况还会一直下去,那还有什么意义呢?这也是导致最后分手的最重要的导火索吧。不过在笼子了养了几个月后,这臭猫没有任何不适,反而还胖到了十斤,靠。

""

分手后,心理学出身的、最为了解我的EX对我进行了有生以来接收到的最严肃的一次、针对内核的精神攻击。作为标准INFP,受到自己认可且曾经信任的人的针对性攻击,让我的精神状况恶化了许多。她说的那些自然没错,但我也确实在不断反思和逐渐自愈,而这一次攻击让我整个人状态有了很大的变化。其初步表现为越来越难入睡,后续就是精神不振以及社会功能(包括工作和创作)的影响。一再坚持的我终于在一个医生朋友的建议下去了广州最好的、中山三院的精神心理科。并最终确诊了ADHD和轻度双相障碍,医生开了一种没有依赖性的药帮助从调节睡眠开始,好在吃了一段时间后渐渐缓解了,睡眠相对恢复了正常,社会功能也逐渐回归,目前完全恢复。

不过这也并非完全是折磨。首先,在中国社会下,作为一个少有的没有病耻感、不被污名化影响的个体,我用身心充分体会到了中度病人的日常状态,在体会到患者痛苦和各种特征的同时还具备不崩溃和记录事实的能力,这也是得以快速恢复的主因。与此同时,在精神心理科的见闻也让我有一些其他的真实感受(。其次,我也学会了和自己慢慢和解,明白了从小的很多问题根源(比如ADHD,导致记忆力差、注意力很难集中、粗心大意),更加释然得看待自己不擅长的东西,并能够坦然得继续努力了。

病耻感是很多病人不愿去就医的重要原因,这很大层面来源于社会的污名化氛围,希望大家能多包容担待一些吧,认知越早,介入越早,越好治愈,后遗症越小。

在逐渐走出了这种状态后,我重拾了话剧、电影、看书等爱好,基本坚持了每周一部话剧或经典电影,一个月一两本文学书籍的习惯,也参加了公司对视障群体的公益活动,同时投入在二刺螈的精力也确实是少了不少,游戏虽然还是照买但也只通关了寥寥,可能是精力上确实不够用了。当然,因为注意力涣散,我的阅读能力一直是比较弱的,但也因此会不断咀嚼,反而读得精了很多,也算一个代偿。

印象最深的是通关了Kan Gao老师的最新作《Imposter Factory》,以及一直关注的カンザキイオリP成长为了创作型歌手(笑,以及Neru你在干什么啊),提醒自己了是个废物。

""

""

参加口述影像培训。

""

参加GGJ2022广州场的作品:GGJ2022《lifefil》路演

""

与此同时我开始逐渐收到了后辈们的咨询,其中包括事业发展、情感问题、生活问题等,而我也逐渐将其视为一种扩展圈子的手段。随着交际不断扩大,对象也有了同辈和比我大的朋友,同时也从互联网扩展到了各行各业,很多都见面聊了聊,最终结果上来讲大家都比较满意。我也尽了最大的善意和鼓励(当然也有一些私货,比如推销某些严肃的书籍),而且总的来看,我也获得了一些别的方面的见解,更加多元化得了解了社会。

和不少应届小弟弟聊过,也算是同龄人佼佼者了,但也都比较迷惘、失望和看不到未来,这到底是什么的问题咱也不敢说。只能说绕了一圈以后,我终于明白了“错的确实不是我,而是这个世界,而这世界的本质也从未变过,大都一种轮回”

而作为这一段时间的背景,铁quan的种种行为一开始让我更加失望了,但却渐渐也能同时站在多个角度想问题,毕竟现实来看,并不存在乌托邦,先避轻就重吧。虽然这一看,我的理想主义浓度似乎降低了,这可能不是什么好事,但从世俗来讲确实更方便一些,也可以认为是避轻就重吧,毕竟精力还是有限,但也做好了更多从未想过的打算。

除了自我之外,在交际方面我也有了一些变化。单身后在网上的留意从一开始的强目的性,很快演化成了圈子的扩大,无论男女积极扩展交友。由此为某些契机,我逐渐意识到了自己在互联网圈子呆了太久,最后是不是会变成自嗨?所以我开始走出小圈子,开始认识别的圈子的人。带着尝试,我逐渐认识了许多不同行业的朋友,然后发现果然还是和汉子/中性思维妹子交流更快乐(逃)。除了线上,我也尽力在线下进行交流,确实了解到了不同行业的一些见解。但我觉得还是不够,因为大都还是相对浮于社会之上的一些行业,希望以后能够认识更多其他领域的人吧。

当然,老朋友也断续有交流,尤其是两个在广深的大学舍友。其中一个已经创业成功公司运作良好却仍文青,另一个虽然是算法但仍在坚持了音乐理想在憋专辑,加上虽然很失败但尚且还在为理想苦苦挣扎的我,让我很欣慰——大家果然都没怎么变。

走出小圈子的一个另一个作用就是,能客观承认自己是吃了上一期的互联网红利了,能够正确得认知自己,也能正确认识到有更多人吃了比我多得多的红利,也尝试理清楚自己的屁股到底在哪一方,当然这个也和我不断阅读的著作有关,产生了一些反思。但却是有时候也会反思过猛,一时间犯上某些类比谬误、情绪上头等问题,还好有朋友讨论,最后都趋于客观。

首先是对“无知”的傲慢,这产生了很多偏见。由于经常在网上看到一些奇怪反智的言论,我感到很生气,故而往往会给群众扣上一个“无知”的帽子,进行批判。但在不断阅读和反思后,我却最终迟迟发现了真正应该批判的并非那些被带节奏的大众,而是“失去了良心的人”。很多大众之所以会被打节奏,很多情况下本质上出于他们朴素的热心。正如托尔斯泰在《复活》中所言:

一种坏行为只是为其他坏行为铺平道路而已,可是坏思想却拖住人顺着那条路走下去,一发而不可收拾。

群众可能只是被利用,但至少大都有朴素的道德感。而产生失去了良心的人却会通过制造景观、操纵情绪、制造对立等手段,刻意破坏这种朴素。而我也曾被这种思想所荼毒,那是一种现在想起来令人不耻的、对我所认知的“无知之人”的优越感。所以在接下来的人生里,我首要做到的是尽力鄙弃这种偏见,虽然一时无法完全做到,但会非常努力。当认清了这一点后,我便愈发讨厌很多自诩清高的所谓知识分子,是的尤其是某乎某瓣上的某些小资文青。

这里我很佩服一个朋友,作为深二代放弃了优越生活,支持父母卖了房去养老,然后自己在城中村过着极其朴素的生活。他的能力可以轻易做到年入大几十万,但却放弃了这一切,只是为了坚持信念追逐自己的理想,而他的理想我不能在这里明说(说来也是讽刺),只能祝福。在前不久聊了几句,很庆幸他还是保持着理想并未妥协,思想也更成熟了。我自认做不到他那样,因为生存焦虑从未离开过我,毕竟主观上我无法依靠任何人。这种焦虑可能最终能够被克服,但确实也需要时间。

承认了互联网红利,意味着我承认了在能力和坚持的成之外,运气和红利也有不小比例。自己的兴趣方向和资本大方向正好一致,本就是一种运气。我见到了太多比我能力强的人没有得到应有回报,也有不少比我弱却将红利当能力而洋洋自得的人。我本身就是一个对钱没太大欲望的人,一旦进一步想清楚,对金钱这些也就更淡然了,完全视其为实现理想的工具。毕竟钱这种东西,一旦你落入它的陷阱,就只会越来越物化自己

工具看使用者的信念,如果一个人的信念是如一的,这种短暂的曲折迟早会拐回来,这也是我花了大量时间内耗、并且写那些看似“矫情”的文章的真正意图——即为了让自己清醒。我之前为何要尽可能快得赚钱?为何这么急着想要解决生存问题?背后的动机自然是一致的——第一,我一直持有的生存焦虑需要克服,无法依托于任何人,克服了它有助于我的创作;第二,我不希望我的作品被外部控制,无论是众筹还是投资(事实上也拒绝过),无论成品如何,这都完全是我的作品。这就要求我去用能赚钱的工作反哺我真正想做的事情。

从这些来看,值得庆幸的是感觉现在本质上和大学毕业并没有变,虽然世俗焦虑多了一些,但能力也多了一些,算是两相抵消。但自己也确实没有当年那么可爱了,虽然极力避免,但少年气仍在变少,也就是不可避免得更加成熟了,也因此性格和心态都有不小的改变。但只要内核不变,那么外在的变化也总有个上限,毕竟在庆生文的最后我也言“成长必然伴随着对美好事物的绝望,所以,不要再成长了”。

最后有个插曲,就是之前拒绝了界面新闻记者的采访,现在想来其实也不完全必要拒绝,毕竟还可以认识记者这个行业的人。但当时的心态很简单——现在的媒体不仅早已脱离群众,还喜欢煽风点火,将那些朴素的人化作武器,去进行互相攻击,而坐收渔利。近一年由于各种原因,网上的互相攻击确实越来越多,反而是Galgame论坛最为友善,说起来也是讽刺。这些攻击中有不少网络暴力,而不太会隐藏自己的我,也陷入了一些纷争和非议,从而被进行了所谓的“抨击和批判”。

抨击

这些抨击来自于各个方面,但主要是来源于自我的表达招致的攻击(从网易内网打拳到脉脉也是刷新了我的的认知)。而这些最后无外乎都集中于几点,若非涉及到他人的要求和利益,我很坦诚也不需要隐藏什么,毕竟也实名上网多年。当然,被迫成熟的我也早已明白,出于人性中非善意的一面,公开表达自己必将带来恶意,并且这些恶意与你自身努力到什么程度无关。毕竟理解你的人无需解释就会欣赏,不理解的说再多也没用

当然有朋友说的也对,选择做自己的代价必然是有的。以前我总是抱着最大的善意揣测他人,太过容易相信他人的言语,吃了很多亏也付出了不少代价,便逐渐学会了有底线的善良。也真正明白了何为“以德报德,以直报怨”。对我的错误揣测和恶意污蔑是这种表达的必然,毕竟对于有些人,只要看到了一些特质的存在就会刺痛他们本身。含糊的逻辑、鸵鸟话术、打压、隐藏的拜金、自我物化等等特质,都是我曾经从未揣测过他人的,但现在也不得不承认很多人确实会如此

首先是“爱现”和“自大”。“满招损谦受益”、“刚易折”这些个所谓的“古训”,读了十几年书、进入社会六年的我,都听得有些腻歪了。我自然懂得所谓的稳重内敛在中国社会规训下能带来的诸多好处,也懂得如何将自己包装成一个大家都喜欢的所谓“年少有为、谦逊沉稳的好青年”,也知道这样对无论是职业发展还是虚伪的交际都有不小增益。但我只不过是做出了“选择”。不加掩饰表露自我的代价我当然明白,但我认为世界上总需要一些人去做这种“选择”

谦逊是美德,但恕我直言,相当多的人嘴里假惺惺的“谦逊”只是打压的工具罢了。现在某些既得利益者,不涨本事只涨世故,动辄就是一套“聪明 -> 智慧 -> 做人的智慧 -> 城府”的劣化逻辑,来对年轻人洋洋自得,也是可笑。诚然在某些境遇和职业下不得不如此,但一帮自己没多大本事的人,试图硬生生这帽子扣在我头上,就显得匪夷所思。事实上我对自我认知非常清晰,在专业领域面对有真才实学的人,亦或是内心善良淳朴坚强的人,是相当谦逊并且保持尊重的。但自己没几两墨水还想来教训我的,恕我报以鄙夷。我发表的内容只是在客观记录自己失败时的迷惘、对自我的怀疑、求而不得的痛苦,自认问心无愧。

不加节制的所谓“谦逊”要求显然是传统文化的一种糟粕,也是导致很多家庭教育、孩子性格扭曲的毒药,也就是所谓的“打压”。这种打压式教育在80/90后并不少见,我也是这种打压荼毒的一员,并一度成为过打压者,但现在理清楚这一切之后也渐渐改变了,也可以认为是对“怯懦”这种罪恶的反抗。比较和怯懦都是最可怕的罪恶,大部分悲剧也由此而生,希望大家、尤其是家长能够早日脱出这个泥泞。

然后是“矫情”。这个词的本意是“故作,假装,掩饰真情”,而现在却被滥用了。同样的话,在不同人口中分量不同。你没有经历过,不代表它就是矫饰的。我无意去辩驳过去的作品包括情绪是否是“矫情”的,但确能保证都是真情实感,对内心的再三诘问和不掩饰如果也算是矫情,那么躲在暗处掩饰自己恶毒攻击一个并非真正了解的人就是Real了?当然无论如何,我都仍然认为那些所谓的“矫情”我一生宝贵的财富(正如我没有因为自己的原因删除任何的QQ空间/朋友圈/知乎/B站等等的文章,我个人是不认为这些是黑历史的),即便是现在描写越来越克制,但当时的那份情绪和记录仍然是真诚、可贵和值得铭记的,至少证明我不断的反思和对自我的诘问,这份坦然自认可贵。除非涉及他人利益,我不会抹掉任何过去的痕迹。倘若一个人如果抹掉和极力否定过去,那还能是他自己吗

对于“爱好”的抨击就更可笑了。我一技术从业者,自认在专业领域做的挺好,同时还能无偿输出自己的技术心得,尽可能无差别帮助能帮助的新人,这是一种怎样的康米主义精神?不过就是同时爱好游戏和文学,我能够成功从做硬件出身迂回转到了游戏,并把文学当做自己一生的终极目的去培养,且切实投入了不少精力去坚持和输出,已然是尽力,努力尝试过的朋友应该知道这背后是多少代价。有什么不能堂堂正正展现的?而文字水平我也从未说过自己有多高,也明知自己没有天赋只能靠努力,并且在不断阅读、锻炼文笔、努力进步。圈内专业出身的好友尚且认为我对情感细腻的捕捉和表达算天赋,只是还需要磨练技艺和坚持,我自认又不是什么天才,才28岁的年纪,还并非是全职(大部分精力卷事业)的情况下,仍在持续的坚持和磨练、发现问题解决问题。

对原生家庭的恶意,是我觉得最可笑也是最缺德的。我的原生家庭确实存在问题,但一方面在不断艰难自我疗愈的同时,另一方面这样的家庭背景下我仍旧没有崩溃和自暴自弃。先努力脱出靠自己不依赖家里,自己解决生存问题获得相对于大部分人的、不被控制的自由,最后还保持着善良正直和社会责任感,已然是无愧于心。接受不可改变的东西,努力改变可以改变的东西,是我尽最大努力做到的成熟和自我的和解了。同时我也利用自己的能力尽力尝试为和自己相似境遇(事实上90后家庭这种原生家庭并不在少数)发声,目前做的独立游戏剧本就涉及到不少这些问题。而某些键盘侠喷身高颜值也就罢了(当然这方面我现在也并不自卑),喷原生家庭真是缺德到家。我现在已经不太会因为这个受伤,但要替很多出身不幸却坚强善良的朋友们骂一句

这里提醒一下某些恶意的人,原生家庭是不可选择的,如果你恰好投胎到了一个80/90这一代概率并不高的、幸福的原生家庭,请好好珍惜,然后对没有你们条件的人抱有起码的尊重,可以不去理解和共情,但不要去伤害。我的心理已经算足够强大了,但大部分童年受过创伤的人都要用很久去治愈,他们很脆弱,而你的恶言在无意识中可能就是下一个凶手。如果有这么阴暗的想法,请早点滚回你的垃圾堆,不要出来丢人现眼。

建议

当然书看的越多,越是能发现这个世界的混沌和无序,理想主义便成了一种宿命性质的慢性自杀。欲戴王冠,必承其重;为众人抱薪者,必被人批倒批臭。世上没有无代价的选择,求而不得是大部分人生的底色,人性如此,世间如此。所以无法得到所有人的理解和认可是一种必然,但仍然有理解的朋友给了某些善意的建议和鼓励,我认为是很有价值的:

  1. 好友R——你表露出的一些坦诚,出于同情传达出的真相,却有可能恰好揭露了他人的伤口,从而成为苍蝇们的盛宴,最终受伤的还是流血的人。这可能反而会无意中伤害到你想保护和同情的人,所以即便是不为自己考虑,也想想他人吧。
  2. 文学博士朋友——读书多经历事情多,便容易满口满心“苍生大义,宏图大展”,却忽略了小我,对着身边那些爱你的人视而不见,再过个十几年,当心成为我认识的那种“内在脆弱,张牙舞爪,外强中干”的中年人。
  3. 戏剧出身策划朋友——你所期待的那些熟读各种严肃文学、真正理解苦难还有创作意识的品质,在TOP文学系和大厂策划中的妹子也极为稀缺,你的这个学校和职业的限定其实意义不大,还是别有这种学校和职业的执念了。同时也没必要太在意别人的看法,你的特质注定了理解的人特别喜欢,不理解的特别讨厌,无愧于心就好。
  4. 不知名的安慰——希望你在被人打击,感到痛苦的时候,知道有个人默默爱着并支持着你,不要焦虑,迟早有一天能遇到和你同频而势均力敌的人。

在这两节的最后,我想用卡尔维诺在《看不见的城市》中的结语来收个尾,与大家共勉:

生者的地狱不会出现,如果真的有,那便是已存于此,我们天天生活在其中,并在一起集结而形成的。免遭痛苦的办法有两种,对于大多人,第一种很容易:接受地狱,成为它的一部分,直至感觉不到它的存在;第二种有风险,要求持久的警惕和学习:在地狱里寻找非地狱的人和物,学会辨别他们,使他们存在下去,赋予他们空间。

未来

对于未来,我的规划相比之前简单了很多。

最首要的,是克服“傲慢”和“怯懦”这两个对于所谓知识分子最需要警醒的罪恶,失去良知比无知更为可怕

事业上继续坚持全力以赴,将自己的工作做到最好,但不会像以前那么卷了。

技术上会继续实用主义的发展,下个阶段应该也是独立游戏的游戏制作阶段了,需要各种效果做好演出。

理想上会继续坚持Deadline制度,有了大学舍友帮忙制作配乐,后续认识的欣赏我的朋友参与美术。无论如何,将其“完成”并问心无愧才是最重要的。

写作上我会减少创作型输出的数量,但会在优先持续保持并扩大阅读量的前提下,去做针对性训练。毕竟有沉淀,才能有素材去创作,才能真正走出“自我”。同时我也会鄙弃之前对中国现代文学的偏见(或者说是恐惧),对中国青年作家的作品进行大量重点阅读。不看不代表差距不存在,坦然接受,才有进步的可能。

生活上可能对自己更好一些吧,在保持底线的状况下多了解真实的世界。同时尽量健身(买了个划船机放家里),野蛮体魄,文明精神,才能活得久一点。毕竟从近两年来看,未来会越来越精彩,指不定就见证什么历史了。当然这个不奢求,我对自己的人生的结局,仍然有一个比较悲观的预期,毕竟孕育出这希望和理想的存在根基的,是一种绝望而荒诞的底色

""

总之,就是尽量达到理想、事业、生活的三方平衡。

至于感情之类的问题就佛吧,反正不靠家里也不需要理会催婚,之前遇到过互相欣赏的也都不在广州。在充分意识到势均力敌、灵魂共鸣对INFP的必要性后,已经大幅降低了成家概率上的期望。现在我也真的慢慢学会了一个人孤独状态下良好得生存,获得一种相对平衡的状态,也是一件好事,毕竟现在第一重要的仍然是“完成理想”。而这些经历带来的另一面,则是我想要的东西越来越少,对很多东西不再害怕失去,隔绝的能力变得更强了,同时也愈发害怕进入关系。不过没办法,想要真诚还不受伤就必须先要学会自我保护,经历过得都懂吧。

之后无论是出于什么目的,应当都会更加会回归生活而非网上的符号了。会继续交一些朋友,抛去偏见了解更多群体。不过我对真心朋友的要求仍然是“能理解这种残缺,努力思考走出困境,有所坚持不完全妥协,势均力敌”的同类。不妥协要求,是对过去选择和代价的尊重;坚持标准,是为了避免完全滑入庸俗

总而言之——保持好奇心和创作欲,坚持学习和阅读,同时学会在严肃的底线下,尽量真实地生活

而生活的第一步,就是即便是一个人跨年,也要自己做个过得去的年夜饭(笑),祝大家新年快乐。

""

正文-过年

“你以为靠这些漂亮的废话,就能实现事业、理想和生活的平衡了?”少年H不耐烦地看完了手机上的文章,语气中充满了戏谑:“骗骗别人也就罢了,还想骗我。”

“我有别的选择吗?没有。”青年H的声音是从卧室传来的。他本来和少年一起坐在客厅的沙发上,但就在跨年的十分钟前,他忽觉一种深深的疲惫重重压到了身上,紧接着是透过躯体的彻骨寒意。他生平中从未体验过这种这种寒冷,身体止不住打起了颤,便连忙冲到卧室里,将空调开到了最大后,将自己裹进了加厚的被子和毛毯中。

“外强中干,虚张声势。”少年离开了沙发,坐到了卧室里的床边,看着还在发抖的青年:“口口声声‘选择的代价’,这下子代价来了,滋味不好受吧?”

“......”青年沉默了稍许,口中吐露着幽怨的调调:“你管我,我这是苍生大义,理想,理想主义者,懂吗?”

“够了够了,还‘苍生大义,理想主义者’,这里没别人,就别逞强了。”少年拿起身边的遥控器,在青年眼前晃了晃,然后关掉了空调:“我知道你觉得冷,但也别把自己闷坏了。”

“你这是想把我冻死!”没了空调的制热,青年觉得更冷了,但他又不想把手从被窝伸出来,便只能气急败坏:“不,你明明是想让我死在烈火中!但我却要在这被冻死了。”

“行了,别生气了,我给你赔个不是,都是我的错。不过无论如何,烈火焚身总比冻死强吧?指不定还能浴火重生。”

“浴火重生?不过是残渣的重组罢了。而且你可别认错,你要是认错,那我所做的一切,就都没有任何意义了。”

“那倒不一定,要不是一直被我督促着‘朝着彼岸骄傲得灭亡’,你现在指不定在哪美滋滋过着好日子呢。”

“我又没怪你,这是我自己选的。从做出选择的每一刻起,命运的车轮便开始转动,它将碾过一切,让我永无安宁。”

“唉,又是这种严肃升格的调调。”少年无奈地摇了摇头:“你当然不会怪我,不过其实偶尔怪怪我也没什么,要不感觉再下去你真的会走向灭亡。”

“灭亡就...!”

“饺子好啦,出来吃吧。”

忽然,少女H的声音从客厅传来,打断了他们的对话。在他们斗嘴的这段时间,少女在厨房煮好了饺子,已经端到了客厅的桌上。

“......”两人默契得沉默了,随后异口同声:“来咯!”少年连忙起身,青年犹豫了一下,但也还是离开了被窝,颤悠悠地走向了客厅。

“您是冻着了吧?现在是有点冷。”少女望着还在打颤的青年,伴随着关切的询问递过去了一碗饺子汤:“来,先喝口汤,暖暖身子。”

“谢谢。”青年接过了汤,将嘴凑到碗边先是抿了一下,随后吹了吹气,连忙喝了两口:“唉,暖和多了,还是你好。”

“啊哈哈,小事一桩。毕竟您告诉过我,无论如何,都必须要保证纯粹和善良。”

“无论如何,都必须要保证纯粹和善良吗...”青年望着面前的少女,她正用那双天真的眼睛注视着自己。青年忽竟感到鼻子一酸,忍不住流下了眼泪。泪水落到了到了饺子汤里,看来是不能喝了。

“您这是怎么了?新年第一天要开开心心的呀。”少女将不能喝的饺子汤接了下来,放到了桌子上,随后递了一张纸给青年。

“再过七个月就二十九了,还在大过年的哭鼻子,像话吗?你们叽叽歪歪的时候,我这饺子都快吃饱了。”和冷漠的语气不同,少年将几个饺子挑到了一个小碗里,撒上了醋、酱油和辣子混成的蘸料,端着递给了擦完眼泪的青年。

“......”青年沉默着,不愿意接下。

“你还在担心啊?没事没事,这个不算‘和解’。”少年笑了笑,硬是将碗塞到了青年的手中。那是14岁的男孩子特有的的笑容,无忧无虑,带着坚定的希望。

青年呆呆地望着手中的碗,他觉得里面的是饺子,又像是其他的什么东西,始终无法下嘴。

“你做事老是这么大大咧咧,筷子都没给。”少女见青年迟迟没有开吃,意识到了什么,抱怨了少年两句后,递上了一双筷子。

“你看看过去这两年发生的事都把他给整傻了,筷子都要人递,是不是还得给他喂啊?”听到少女的埋怨,少年非常不满。

“......”青年没有理会少年的嘲讽,接过了少女的筷子,夹起了一个饺子,咬了一口,却感觉有什么硬的东西在里面:“唔,这是?”

“一定是硬币!”少女欢呼了起来:“我往里面放了个硬币,百分之一,小概率。您运气真好!”

“唉,又是小概率。”和欢呼的少女不同,少年见到此景则是愁容满面:“算了,你的命就是如此,要不我俩也不会在这里。”

“说的是呢。”少女也意识到了少年觉察到的问题,小心翼翼问了句:“您还和以前一样,渴望着小概率吗?”

青年低着头,望着已经吐到了手中的硬币,沉默良久,然后说道:“我...不知道。”

“唉,行,那我也不急着去长期旅游了。”少年看着青年的样子,叹了口气:“就再陪陪你吧,陪你想一直困扰你的那个问题。”

“经历了这么多求而不得,漂泊生活过这么多地方,体验过这么多的无常。你却仍旧不知足,不停观望着下一个去处。那么——”

“你所真正期望的,到底是什么?你最终的归宿,又究竟在哪里?”

“......”

青年沉默了稍许,放下碗坐到了沙发上,随后望着不知何时出现在前方的镜子。而镜中他也望着自己,他们都如此得疲惫。

“镜花水月,梦幻泡影,繁荣的背后尽是腐朽。”

“我只是在不断追寻,期望能从中找到什么阻止自杀的理由罢了。”

“但我现在很累,已经没有什么想要的,也没有什么想说的了。”

人生以来第一次,只属于他们三人的春节,就这样开始了。

WebGPU实时光追美少女

作者 dtysky
2021年10月6日 06:00

""

作为一个老二刺螈,我进入这个行业的最初动机可以追溯到十年前打通了《Ever17》的那个下午,这个动机就是——美少女。做一个美少女游戏,是我人生的悲愿,而为了完成这个愿望,我必须要从头开始,学习编程、图形学、编写渲染引擎、乃至实现游戏引擎。而在硬件高速发展的现在,实时光线追踪成为了可能,同时Web平台上的新一代图形APIWebGPU提供了丰富的能力也可以让我们进行这样的尝试。

所以为了渲染一个美少女,我一边学习一边实现,最终完成了这个项目和系列文章教程。本系列文章将会论述如何用WebGPU来实现一个实时路径追踪渲染器,从一个简单渲染器为开端层层深入,了解经典路径追踪渲染器的各个部分,以及BRDF模型在路径追踪中的实现。

当然,最后因为性能太渣,真实感的美少女并没有被渲染出来,只能渲染一个LowPoly的Miku555

项目

项目的Github仓库为:dtysky/webgpu-renderer

Demo为:Demo,注意目前需要最新的Chrome Canary版本,并且打开特定flag才行,详见项目的readme

文章

  1. 概览介绍:将会对整个项目涉及的知识做一个综述。
  2. WebGPU基础与简单渲染器:通过自己实现的一个简单渲染器来论述WebGPU的能力。
  3. 路径追踪-场景数据组织:针对路径追踪,如何组织场景数据,涉及到PBR材质、glTF、场景合并等。
  4. 路径追踪-管线组织与GBuffer:针对路径追踪,如何组织渲染管线,同时论述GBuffer的生成。
  5. 路径追踪-BVH与射线场景求交插值:如何构建BVH,以及如何在CS中求交。
  6. 路径追踪-BRDF与蒙特卡洛积分:论述如何在路径追踪中运用蒙特卡洛采样实现直接光照和间接光照,以及运用BRDF光照模型。
  7. 路径追踪-降噪与色调映射:如何对充满噪点的图像进行空间和时间的滤波,最后输出如何进行色调映射。
  8. 踩坑与调试心得:对于WebGPU这样一个实验性的API(至少当时),我是如何进行调试的血泪史(主要是CS部分)。

后记

由于本人水平有限,文章难免会有纰漏,欢迎各位在评论区积极指正。

以及即便是能搞这些了,我还是做不出来我的美少女游戏,哎......

【WebGPU实时光追美少女】踩坑与调试心得

作者 dtysky
2021年10月6日 03:30

本系列文章设计的所有代码均已开源,Github仓库在这里:dtysky/webgpu-renderer

在前面的七篇文章中,我从概论开始,论述了简单WebGPU渲染引擎的实现,并实现了一个支持BVH加速结构和BRDF光照模型的实时路径追踪渲染器。但由于WebGPU的API的实验性,目前相关标准仍然可能不断变更,而由于配套于WebGPU的调试工具还不存在,所以在不重编Chromium的情况下只能想一些朴素的方法来调试,这里就记录了一些调试心得。

教程基础部分到此完结,BSDF和BVH优化部分等有生之年吧。又在焦虑中做到了一次无偿的知识分享,算是以此再次纪念“互联网之子”亚伦·斯沃茨

关于API变更

目前WebGPU的API标准仍然可能变更,所以发现之前跑得好好的代码忽然就挂了也是正常的,当遇到这种问题后,我们一般需要去以下几个地方寻找解决方案:

Chromium WebGPU部分的Issue: https://bugs.chromium.org/p/dawn/issues/list

WebGPU最新标准:https://www.w3.org/TR/webgpu

着色语言WGSL最新标准: https://www.w3.org/TR/WGSL

同时有时候报错会不清晰,这时候可以直接看Chromium的WebGPU模块源码来分析:

Dawn: https://dawn.googlesource.com/dawn/+/HEAD/src/dawn_native

如何调试CS

对于有经验的开发者来说,相对而言渲染的部分比较好调试,实在不行可以使用朴素的Color Picker大法,毕竟有了数据什么都好说,并且对于路径追踪来讲渲染部分并不复杂,不需要怎么调试。

Compute Shader部分就不同了,在路径追踪实现中我大量使用了CS,每个部分都并不简单,而且环环相扣,出了问题十分难以排查,下面每一部我都遇到过问题:

  1. 射线生成。
  2. BVH求交。
  3. 三角形求交。
  4. 重心坐标插值。
  5. 重要性采样。
  6. BRDF着色计算。

比如在一开始,我遇到过这种情况:

""

当然对路径追踪十分熟悉的朋友,应该一眼就能看出这可能是射线生成的问题,但当时刚开始学习并没想到这个,况且就算想到了也需要有方法来确认和调试,为了调试,我使用SSBO构建了一条用来调试CS的流程。

SSBO

SSBO即Shader Storage Buffer Object,在前面的主流程中也使用过,用于存储合并过的场景数据。这种数据可以被CPU和GPU共享,不但可以用于从CPU向GPU传输数据,也可以将数据从GPU到CPU读回来。虽然这两个过程都比较慢,但对于调试而言已经足够。

在构建流程之前,需要先明确调试的步骤:

  1. 路径追踪过程始于屏幕空间的像素,所以以像素为入口调试。
  2. 构建一个屏幕大小的SSBO来存储CS计算过程中的信息,SSBO中的数据结构可以按情况定制。
  3. 可以用一个简单的策略,读回需要的GPU数据,在CPU端复刻算法进行的调试。

梳理清楚流程后,我编写了DebugInfo类以及各个Debug方法,比如debugRaydebugRayShadow,它们都在demo/debugCS.ts中。

DebugInfo

DebugInfo提供了给RayTracingManager中用于路径追踪的计算单元rtUnit注入调试信息的能力,这个调试信息通过一个SSBO作为Uniform传入,骑在Shader中的定义为:

struct DebugRay {
  preOrigin: vec4<f32>;
  preDir: vec4<f32>;
  origin: vec4<f32>;
  dir: vec4<f32>;
  nextOrigin: vec4<f32>;
  nextDir: vec4<f32>;
  normal: vec4<f32>;
};

[[block]] struct DebugInfo {
  rays: array<DebugRay>;
};`

其对应的Interface和具体构造为:

interface IDebugPixel {
  preOrigin: Float32Array;
  preDir: Float32Array;
  origin: Float32Array;
  dir: Float32Array;
  nextOrigin: Float32Array;
  nextDir: Float32Array;
  normal: Float32Array;
}

export class DebugInfo {
  protected _cpu: Float32Array;
  protected _gpu: GPUBuffer;
  protected _view: GPUBuffer;
  protected _size: number;
  protected _rtManager: H.RayTracingManager

  // 构造SSBO并作为Uniform注入给计算单元
  public setup(rtManager: H.RayTracingManager) {
    const {renderEnv} = H;
    const size = this._size = 4 * 7;

    this._cpu = new Float32Array(size * renderEnv.width * renderEnv.height);
    this._gpu = H.createGPUBuffer(this._cpu, GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_SRC);
    this._view = H.createGPUBufferBySize(size * renderEnv.width * renderEnv.height * 4, GPUBufferUsage.MAP_READ | GPUBufferUsage.COPY_DST);

    this._rtManager = rtManager;
    this._rtManager.rtUnit.setUniform('u_debugInfo', this._cpu, this._gpu)
  }

  // 拷贝SSBO,具体原因详见下面
  public run(scene: H.Scene) {
    scene.copyBuffer(this._gpu, this._view, this._cpu.byteLength);
  }

  // 从GPU读回数据,并指定需要解析的区域
  public async showDebugInfo(
    point1: [number, number],
    len: [number, number],
    step: [number, number]
  ): Promise<{
    rays: IDebugPixel[],
    mesh: H.Mesh
  }> {
    await this._view.mapAsync(GPUMapMode.READ);
    const data = new Float32Array(this._view.getMappedRange());
    const rays = this._decodeDebugInfo(data, point1, len, step);
  }

// 将读回的数据进行解析,变成可理解的结构
  protected _decodeDebugInfo(
    view: Float32Array,
    point1: [number, number],
    len: [number, number],
    step: [number, number]
  ) {
    const res: IDebugPixel[] = [];
    const logs = [];

    for (let y = point1[1]; y < point1[1] + len[1] * step[1]; y += step[1]) {
      for (let x = point1[0]; x <  point1[0] + len[0] * step[0]; x += step[0]) {
        const index = y * H.renderEnv.width + x;
        const offset = index * this._size;
        res.push({
          preOrigin: view.slice(offset, offset + 4),
          preDir: view.slice(offset + 4, offset + 8),
          origin: view.slice(offset + 8, offset + 12),
          dir: view.slice(offset + 12, offset + 16),
          nextOrigin: view.slice(offset + 16, offset + 20),
          nextDir: view.slice(offset + 20, offset + 24),
          normal: view.slice(offset + 24, offset + 28)
        } as IDebugPixel);
      }
    }

    return res;
  }
}

在这段代码中,需要注意的是我创建了两个GPUBuffer,一个设置给了uniform,另一个则用于读取,而期间做了一次拷贝scene.copyBuffer,这个拷贝的实现为:

this._command.copyBufferToBuffer(src, 0, dst, 0, size);

这是由于在WebGPU标准中,对GPUBuffer的使用有所限制,STORAGE不能和MAP_READ共存,也就是说一个Buffer不能又可以被CPU读又可以被GPU读,所以必须要先将其拷贝到另一个GPUBuffer中,再进行读取。

在GPU中填充数据

在流程组织完毕后,便可以在GPU中对SSBO进行填充,这个可以根据不同场景有不同的设置,比如这里是:

var hited: f32 = 0.;
if (hit.hit) {
  hited = 1.;
}
ray = startRay;
hit = gBInfo;
light = calcLight(ray, hit, baseUV, 0, false, false, debugIndex);
u_debugInfo.rays[debugIndex].origin = vec4<f32>(ray.origin, hit.sign);
u_debugInfo.rays[debugIndex].dir = vec4<f32>(ray.dir, f32(hit.matType));
ray = light.next;
hit = hitTest(ray);
light = calcLight(ray, hit, baseUV, 1, false, false, debugIndex);
u_debugInfo.rays[debugIndex].nextOrigin = vec4<f32>(ray.origin, hit.sign);
u_debugInfo.rays[debugIndex].nextDir = vec4<f32>(ray.dir, f32(hit.matType));
u_debugInfo.rays[debugIndex].normal = vec4<f32>(hit.normal, hit.glass);

我将初始射线的参数和在CS中求得的下一条射线参数记录了下来,以待调试。

在CPU调试指定数据

有了GPU的数据,就可以通过上面的showDebugInfo函数,来通过指定的像素位置和步长,来decode一定窗口大小的数据,只要有一个时机来触发即可:

H.renderEnv.canvas.addEventListener('mouseup', async (e) => {
  const {clientX, clientY} = e;
  const {rays, mesh} = await this._rtDebugInfo.showDebugInfo([clientX, clientY], [10, 10], [4, 2]);
  console.log(rays);
  rays.forEach(ray => debugRay(ray, this._rtManager.bvh, this._rtManager.gBufferMesh.geometry.getValues('position').cpu as Float32Array));
});

这段代码将调试过程和鼠标点击事件关联了起来,注意到最后一句我对每个像素的数据都执行了debugRay操作,这其实就是在CPU内实现了和GPU一样的算法,比如光源采样、BVH求交和三角形求交等等。由于是JS代码,所以很容易进行调试,并且也可以和GPU中存下的结果直接进行对比,虽然也很麻烦,但至少有一个调试的方法了,比如debugRay

export function debugRay(rayInfo: {origin: Float32Array, dir: Float32Array}, bvh: H.BVH, positions: Float32Array) {
  const ray: Ray = {
    origin: rayInfo.origin,
    dir: rayInfo.dir,
    invDir: new Float32Array(3)
  };
  H.math.vec3.div(ray.invDir, new Float32Array([1, 1, 1]), ray.dir);

  console.log('ray info', rayInfo);
  console.log('ray', ray);

  const fragInfo = hitTest(bvh, ray, positions);
  console.log(fragInfo);
}

这里可以看到此调试方法将上面decode好的信息中的origindir作为参数进行了CPU端的hitTesthitTest的实现对于此章节无关紧要,不再赘述。

合理借助外部工具

在实际的调试过程中,往往即便我们有了数据和调试函数,但像是BVH求交、三角形求交这种过程的计算比较复杂,数值上又很难分析出结果,这时候就需要外部工具的帮助了。设想如果有个工具能够将三角形、射线这些都直观得在三维空间内简洁地绘制出来,调试难度会大幅降低。

而这个工具确实存在,而且是免费在线的——Wolfram Cloud,可以认为是Mathematica的在线版,功能其实已经够用了。

对于本项目,用它调试最核心的就是生成各种绘制指令,我在CPU端的调试代码中插入了各种生成绘制指令的代码,比如:

// 绘制点
plotS.push(`Graphics3D[{Red, PointSize[0.1], Point[{${rsiPoints[1].join(',')}}]}]`);

// 绘制射线
plotS.push(`ParametricPlot3D[{${ray.dir[0]}t + ${ray.origin[0]}, ${ray.dir[1]}t + ${ray.origin[1]}, ${ray.dir[2]}t + ${ray.origin[2]}}, {t, 0, ${maxT}}]`);

// 绘制三角形
plotS.push(`Graphics3D[Triangle[{{${p0.join(',')}}, {${p1.join(',')}}, {${p2.join(',')}}}]]`);

// 绘制长方体
plotS.push(`Graphics3D[Cuboid[{${node.max.join(',')}}, {${node.min.join(',')}}]]`);

有了这些指令,最后我们就可以画出类似下面的图形:

""

结语

当然除了调试BVH求交这种问题,像是光照计算等等也都可以通过这种方式输出数据来观察和调试,在开发过程中我也是这么做的。

一开始计划的时候本篇应该还是有挺多内容,但现在回想起来很多踩的坑已经在前面的文章都论述过了,比如16字节对齐GPUBuffer的Usage射线原点偏移等等。

那么就到此为止吧,祝大家国庆快乐。

【WebGPU实时光追美少女】降噪与色调映射

作者 dtysky
2021年10月5日 02:00

本系列文章设计的所有代码均已开源,Github仓库在这里:dtysky/webgpu-renderer

在上一篇文章BRDF与蒙特卡洛积分的最后,我们最终得到了一个充满了噪点的输出,接下来要考虑的就是如何降噪(Denoise),这个分为时间和空间两部分。并且由于整个光照计算都是在高动态范围(HDR)计算的,最后还要做一次色调映射(Tone Mapping)来将其转换到有限的低动态范围(LDR),便于显示器显示。

降噪

实时路径追踪的噪声来自于样本数量不足下的信息量不足,所以有两个方面可以来优化:

  1. 提高样本量。
  2. 尽可能使用有效的样本。

时间滤波(Temporal Filter)就是用来解决这两个问题的。而由于样本的增加总是需要累计时间,所以会有一个启动时间,所以为了尽可能加快速度达到一个相对可看的程度,我们在空间上也会做一些滤波,或者说是图像处理,这就是空间滤波(Spatial Filter)

目前工程上的滤波方案往往是混合滤波,即结合时间空间二者,目前最常见的就是SVGF以及其变种,但由于本项目只涉及到静态场景,并未实现Motion Vector这种动态场景的技术,所以只是借鉴其中的一部分。

""

这里就直接用SVGF的一个流程图来论述整个流程:

  1. 首先求得当前帧的1SPP原始图像。
  2. 和上一次的结果作时间滤波。
  3. 将时间滤波的结果用于空间滤波。
  4. 空间滤波的输出反馈到下一帧,同时作为色调映射的来源,做最终输出。

时间滤波

时间滤波顾名思义,是通过不同帧之间的信息来做滤波。路径追踪的时间滤波比较简单:

$$C_{out} = \alpha C_{i-1} + (1 - \alpha)C_{i}$$

这个公式本质上是个简单的混合,将上一帧颜色和这一帧颜色通过权重$\alpha$混合起来。这个权重的值是动态变更的,我这里取:

$$\alpha = \frac{frameCount - 1}{frameCount}$$

这么做也是有原理可循的,回顾一下上一篇文章所说蒙特卡洛积分的采样相加求平均,我们需要相对保证每帧的结果被均匀平均:

$$\frac {C_{n}} n + \frac {n-1}n(\frac {C_{n-1}} {n-1} + \frac {n-2} {n-1}(\frac {C_{n-2}} {n-2} + ...) = \frac 1 n(C_{n} + C_{n - 1} + C_{n - 2} + ...)$$

所以只要在实现的Shader内接受完结传来的这个权重u_preWeight,然后在JS里不断更新权重即可:

[[stage(compute), workgroup_size(16, 16, 1)]]
fn main(
  [[builtin(workgroup_id)]] workGroupID : vec3<u32>,
  [[builtin(local_invocation_id)]] localInvocationID : vec3<u32>
) {
  let size: vec2<i32> = textureDimensions(u_current);
  let groupOffset: vec2<i32> = vec2<i32>(workGroupID.xy) * 16;
  let baseIndex: vec2<i32> = groupOffset + vec2<i32>(localInvocationID.xy);

  let pre: vec4<f32> = textureLoad(u_pre, baseIndex, 0);
  let current: vec4<f32> = textureLoad(u_current, baseIndex, 0);
  let mixed: vec4<f32> = vec4<f32>(mix(current.rgb, pre.rgb, vec3<f32>(material.u_preWeight)), current.a);

  textureStore(u_output, baseIndex, mixed);
}

空间滤波

空间滤波本质上就是图像处理,最简单的空间滤波就是高斯模糊。

高斯模糊

$$exp(-\frac {(x-x_c)^2 + (y-y_c)^2} {2\sigma_d^2})$$

其以某个像素为中心开一个固定大小“窗口”,根据周边像素到中心像素的半径计算权重,然后将窗口中的所有像素颜色加权平均,最终得到一个模糊的效果:

""

单纯的高斯模糊其实是一个低通滤波器,其本质是在频域将高频噪声过滤,留下低频信号,这种频域的处理就会得到一个如图的看起来很糊的效果,这个糊来源于边界的消失,而边界的消失则是基于以下两个事实:

  1. 高频信息并非都是噪声。
  2. 低频信息也并非不都是噪声。

所以我们需要又一个更好的策略,来在降噪的同时还能保留有效的高频信息,这就要提到联合双边滤波(Joint Bilateral Filter)

联合双边滤波

$$exp({-\frac {(x-x_c)^2 + (y-y_c)^2} {2\sigma_d^2}} - {\frac {||color_{x,y} - color_{x_c,y_c}||^2} {2\sigma_c^2}})$$

变换后为:

$$exp({-\frac {(x-x_c)^2 + (y-y_c)^2} {2\sigma_d^2}}) * exp({-\frac {||color_{x,y} - color_{x_c,y_c}||^2} {2\sigma_c^2}})$$

如公式所示,在方才高斯模糊的基础上,我们加了一个和颜色相关的项,来共同决定窗口中某个像素的权重,同时还可以控制二者的方差来决定像素位置和颜色分别决定权重的比例。那么接下来就可以很自然想到——既然如此,是否可以再加上别的项,通过别的信息来共同决定这个权重呢?当然是可以的,这就又要用到Gbuffer了。

已知Gbuffer中提供了世界坐标、法线等等信息,我们可以考虑对于当前图像“边界”到底意味着什么?很自然可以想到就是一些合理可控的突变的点,比如在一个立方体的转折处(法线发生了较大变化),再比如一前一后的两个物体(深度发生了较大变化)。利用这些特性,就可以构造一个比较合理的滤波器:

$$exp({-\frac {(x-x_c)^2 + (y-y_c)^2} {2\sigma_d^2}}) * exp({-\frac {||color_{x,y} - color_{x_c,y_c}||^2} {2\sigma_c^2}}) * exp({-\frac {||normal_{x,y} - normal_{x_c,y_c}||^2} {2\sigma_n^2}}) * exp({-\frac {||z_{x,y} - z_{x_c,y_c}||^2} {2\sigma_n^2}})$$

然后接下来就是调整系数(方差)的工作了,我在CPU中通过方差事先算好了系数,来供Shader中使用:

export function genFilterParams(sigmas: Float32Array): Float32Array {
  const res = new Float32Array(sigmas.length);

  for (let i: number = 0; i < sigmas.length; i += 1) {
    const s = sigmas[i];
    res[i] = -0.5 * s * s;
  }

  return res;
}

到此就可以实现最终的滤波器了,联合双边滤波的结果如下,可见解决了边界被模糊的问题:

""

Outlier Removal

滤波滤波到此结束,但还有另一个问题,如下图:

""

图上有一些亮点,这些亮点的亮度明显高于周边。这是由于采样率不足,导致没有足够的样本做平均来达到真正的效果,换言之如果样本足够这些亮点是可以被平均掉的。但对于实时应用,我们等不起这个过程,所以必须用方法将它们“抹除”掉。这确实会造成能量的不守恒,但却是一个必要的权衡。

这个过程称为Outlier Removal,采用的是统计的方法:

  1. 在滤波操作之前进行。
  2. 计算所有点亮度的均值和标准差。
  3. 根据均值和方差算出上界,如果某像素亮度大于上界,则判定为Outlier。
  4. 针对Outlier,将其拉到上界范围内。

代码实现

至此,所有的要素都已集齐,就可以进行代码实现了(当然写的有点糙,凑合看吧):

fn calcWeightNumber(factor: f32, a: f32, b: f32) -> f32 {
  return exp(factor * (a - b) * (a - b));
}

fn calcWeightVec2(factor: f32, a: vec2<i32>, b: vec2<i32>) -> f32 {
  let diff: vec2<f32> = vec2<f32>(a - b);
  return exp(factor * dot(diff, diff));
}

fn calcWeightVec3(factor: f32, a: vec3<f32>, b: vec3<f32>) -> f32 {
  let diff: vec3<f32> = a - b;
  return exp(factor * dot(diff, diff));
}

fn calcLum(color: vec3<f32>) -> f32 {
  return dot(color, vec3<f32>(0.2125, 0.7154, 0.0721));
}

fn blur(center: vec2<i32>, size: vec2<i32>) -> vec4<f32> {
  let radius: i32 = WINDOW_SIZE / 2;
  let sigmaD: f32 = material.u_filterFactors.x;
  let sigmaC: f32 = material.u_filterFactors.y;
  let sigmaZ: f32 = material.u_filterFactors.z;
  let sigmaN: f32 = material.u_filterFactors.w;
  var centerColor: vec4<f32> = textureLoad(u_preFilter, center, 0);
  let alpha: f32 = centerColor.a;
  let centerPosition = textureLoad(u_gbPositionMetalOrSpec, center, 0).xyz;
  let centerNormal = textureLoad(u_gbNormalGlass, center, 0).xyz;
  var colors: array<array<vec3<f32>, ${WINDOW_SIZE}>, ${WINDOW_SIZE}>;
  var lums: array<array<f32, ${WINDOW_SIZE}>, ${WINDOW_SIZE}>;

  var minUV: vec2<i32> = max(center - vec2<i32>(radius, radius), vec2<i32>(0));
  var maxUV: vec2<i32> = min(center + vec2<i32>(radius, radius), size);
  var localUV: vec2<i32> = vec2<i32>(0, 0);
  var sumLum: f32 = 0.;
  var count: f32 = 0.;

  // 计算每个像素的颜色亮度存起来
  for (var r: i32 = minUV.x; r <= maxUV.x; r = r + 1) {
    localUV.y = 0;
    for (var c: i32 = minUV.y; c <= maxUV.y; c = c + 1) {
      let iuv: vec2<i32> = vec2<i32>(r, c);
      let color: vec3<f32> = textureLoad(u_preFilter, iuv, 0).rgb;
      let lum: f32 = calcLum(color);
      colors[localUV.x][localUV.y] = color;
      lums[localUV.x][localUV.y] = lum;

      sumLum = sumLum + lum;
      count = count + 1.;
      localUV.y = localUV.y + 1;
    }
    localUV.x = localUV.x + 1;
  }

  // 计算亮度均值
  let meanLum: f32 = sumLum / count;

  // 计算亮度方差
  var std: f32 = 0.;
  for (var r: i32 = 0; r < localUV.x; r = r + 1) {
    for (var c: i32 = 0; c < localUV.y; c = c + 1) {
      let lum: f32 = lums[r][c];
      std = std + (lum - meanLum) * (lum - meanLum);
    }
  }
  std = sqrt(std / (count - 1.));

  // 确定亮度最大边界
  let largestLum: f32 = max(meanLum + std * 2., 1.);

  var lum: f32 = calcLum(centerColor.rgb);
  if (lum > largestLum) {
    centerColor = centerColor * largestLum / lum;
  }

  localUV = vec2<i32>(0, 0);
  var weightsSum: f32 = 0.;
  var res: vec3<f32> = vec3<f32>(0., 0., 0.);

  for (var r: i32 = minUV.x; r <= maxUV.x; r = r + 1) {
    localUV.y = 0;
    for (var c: i32 = minUV.y; c <= maxUV.y; c = c + 1) {
      var color: vec3<f32> = colors[localUV.x][localUV.y];
      lum = lums[localUV.x][localUV.y];

      if (lum > largestLum) {
        // 当前像素亮度大于最大边界,将其拉回来
        color = color * largestLum / lum;
      }

      let iuv: vec2<i32> = vec2<i32>(r, c);
      let position: vec4<f32> = textureLoad(u_gbPositionMetalOrSpec, iuv, 0);
      let normal: vec4<f32> = textureLoad(u_gbNormalGlass, iuv, 0);
      // 联合双边滤波
      let weight: f32 = calcWeightVec2(sigmaD, iuv, center)
        * calcWeightVec3(sigmaC, color.rgb, centerColor.rgb)
        * calcWeightVec3(sigmaN, normal.xyz, centerNormal.xyz)
        * calcWeightNumber(sigmaZ, position.z, centerPosition.z);
      weightsSum = weightsSum + weight;
      res = res + weight * color;

      localUV.y = localUV.y + 1;
    }
    localUV.x = localUV.x + 1;
  }

  res = res / weightsSum;

  return vec4<f32>(res, alpha);
}

色调映射

在完成了滤波后,如果不加任何处理,我们会得到这样的一个结果:

""

可见其有明显的过曝,所以就需要色调映射。评估一个色调映射质量最重要的维度是对比度的保留,历史上出现过很多种策略,像是Reinhard曲线指数曲线等,但都不足够好,会有灰蒙蒙的感觉。直到后期,人们最终找到了高次曲线拟合的方法,最终实现了目前最通用的拟合策略,其计算高效,实现简单:

let A: f32 = 2.51;
let B: f32 = 0.03;
let C: f32 = 2.43;
let D: f32 = 0.59;
let E: f32 = 0.14;  

fn acesToneMapping(color: vec3<f32>) -> vec3<f32> {
  return (color * (A * color + B)) / (color * (C * color + D) + E); 
}

色调映射后的最终的结果如下:

""

结语

本篇文章所言都是针对静态场景的,在这种实现下,物体和相机都不能移动,这在实际的应用中显然是不够的。一般在真正可用的实现中,我们必须引入Motion Vector,来计算当前一个像素上一帧所对应的像素,然后分别存储权重,最终达到动态场景的滤波。但这也会引入更多其他的问题,由于篇幅和时间确实没空完成了。

当然如果只是玩票,理论上加上Motion Vector,对SVGF之类的进行实现也并非难事,这篇文章的很多知识是可以用到的,有兴趣的读者可以自行实现,或者等有生之年我进行优化(逃

【WebGPU实时光追美少女】BRDF与蒙特卡洛积分

作者 dtysky
2021年10月2日 05:00

本系列文章设计的所有代码均已开源,Github仓库在这里:dtysky/webgpu-renderer

在上一篇文章BVH与射线场景求交插值中,我论述了如何对场景进行划分、如何求得射线和场景中三角形的交点、如何判断射线和某点之间是否有遮挡、射线是否与光源相交,以及如何得到插值后的顶点和材质属性。有了这些数据,便可以进行最终的光照计算,这里会涉及到BRDF模型、直接光照、间接光照、蒙特卡洛积分、重要性采样、随机数生成等等的概念和实现。

长假第一天从学习开始,大家卷起来!

BRDF

BRDF,即双向反射分布函数,是一种针对物体表面的光照反射模型,其综合了漫反射和镜面反射两部分,给出了一种比较物理真实的算法。

从渲染方程说起

在一般的渲染中,光照本质上是一个针对“入射光线”(一般由方向、亮度、颜色等描述)击中“表面”(一般由法线、各种材质参数描述)后,求得表面最终颜色的计算。为了统一描述这个过程,在1986年,渲染方程被提出:

$$L_0(p,w_0) = L_e(p,w_0) + \int_{\xi^{2}}f_{r}(p,w_{i},w_{o})L_{i}cos{\theta}dw_{i}$$

渲染方程描述了一个基于辐射度量学和能量守恒定律的光照模型。其中p是计算光照的点,wo是出射方向,Le部分可认为是自发光,后面部分是一个半球积分,其描述了此点入射半球内的光照累加和,其中wi是入射方向,fr是散射函数,Li是入射辐亮度(单位立体角单位面积的功率),cos是法线和入射光线的夹角。

至于为何要用辐亮度,可以参考闫老师的Games101。

可见,除了自发光这个比较简单的参数,其他计算都和“入射方向”、“光照点的材质属性”和“出射方向”有关。而其中最重要的就是fr,即散射函数。那么散射函数到底是什么呢?它描述的实际上是出射辐亮度和入射辐亮度微分的比例,也可以认为是一条光线从特定方向入射、出射时发生的能量吸收后剩余的能量。

不同的光照模型实际上就是在定义fr这个散射函数,尤其是对于PBR(基于物理的渲染),就是在寻找模型来尽量逼近物理真实。

BRDF模型

BRDF就是一种散射函数,其不考虑透射现象,只考虑非透明物体的表面反射和回弹,所以是双向反射分布函数。我们这里使用的BRDF模型定义为:

$$f_{r}(p,w_{i},w_{o}) = k_{d}\frac{c}{\pi} + k_{s}\frac{DFG}{4cos\theta_{i}cos\theta_{o}}$$

可见BRDF模型分为两部分,第一部分为漫反射,第二部分为高光反射,这两部分各自有一个系数,这个系数由菲涅尔系数决定。这两部分基于微表面模型,它将每一个着色点理解为很多朝向不同的微小表面的集合:

""

在这个模型下,每条光线入射某个着色点后,都将以一定的概率反射到不同的方向,并有不同程度的能量损耗。

菲涅尔方程

菲涅尔方程描述了这样的而一种现象——当光从一个介质入射到另一个不同折射率的介质的表面时,一部分会被反射、另一部分会被折射:

""

被菲涅尔反射的这部分就是高光反射,而另外么有被折射也没有被吸收的部分就是漫反射。菲涅尔系数的计算一般和材质属性相关,这个后面会说。

注意对于金属是没有漫反射的,因为这部分能量会被完全吸收掉,这在后面实际的计算中也会体现。

漫反射

漫反射部分从表现上来看,就是一条光线入射到着色点后,没有被镜面反射的部分在物体内部或微表面多次弹射,没有被吸收的部分会随机得反射到整个法线半球中,其出射只和入射方向与法线相关,和观察方向无关:

""

在我们使用的这个模型中,对其抽象比较简单,认为这个随机是均匀的,所以可以看到是一个定值c/pic是反照率,主要由baseColor和不同工作流的系数控制,除以pi是因为fr是将辐照度转换到辐亮度的比例,需要换算。

高光反射

和漫反射不同,BRDF的高光反射部分相对复杂。高光部分本身其实挺简单的,就是根据入射光线、法线、视线计算一个反射比例,从而确定出射光线的方向和强度。但由于微表面模型的存在,着色点并非是一个简单的镜面,我们需要能够模拟着色点表面粗糙的状况,这也就是这部分在BRDF的公式中比较复杂的原因。

如公式所示,高光反射主要分为三个部分:菲涅尔项F、法线分布D和几何遮蔽G。其中菲涅尔项在上面已经论述过;法线分布本质上描述了模型中各个微表面的分布状况,有不同实现;几何遮蔽则是为了描述微表面的反射光线可能被其他微表面遮挡的现象,也有不同的实现。但由于这里只是先说原理,详细的实现会放在后面讲:

""

现在我们有了一个理论上的光照模型,可以在光线的入射和出射方向都确定的情况下计算出入射和出射之间的比例,但对于路径追踪而言,还有以下问题要解决:

  1. 对于这样一个很难给出解析解的积分,如何求解。
  2. 对于实时渲染,几乎不可能一帧完成积分,如何处理。
  3. 如何通过已知的出射光线和材质属性,求取入射光线。

这些问题,都可以通过蒙特积分解决。

蒙特卡洛积分

蒙特卡洛积分是一种统计方法,用于求解一个难以求出解析解的积分的数值解。其基本思路是采样平均

""

如图,f(x)为要求解的积分,我们可以在其定义域上采样,如此便可以产生许多矩形样条,用这些样条去采样然后相加求平均,便可以证明其数学期望与希望得到的积分一致,这也证明通过蒙特卡洛积分得到的结果是无偏的。当然,这个无偏不代表有效性,所以我们必须要保证大量的样本才能使得结果最终收敛于真值。

采样与概率密度

在实际的实现中,我们往往希望尽量减少采样的次数,以最快的速度达到收敛。好在蒙特卡洛积分允许我们进行非均匀采样,即提供一个分布来取代均匀采样,如此一来只要保证无偏,就可以就可能提高有效性,来达到最快的收敛速度:

$$F_{n} = \frac{1}{N}\sum_{i=1}^{N}\frac{f(X_{i})}{p(X_{i})}$$

如公式,其中p(x)是需要提供分布的概率密度函数(PDF)。所以对于我们而言最重要的如何选择这个用于采样的概率分布,这就要提到重要性采样:

""

如图,重要性采样指的是对于被积函数,我们选取的采样分布能够满足随着被积函数而变化——被积函数值越大的地方,采样点选取概率越高。同时有了PDF之后,我们还需要一个方法来采样,然而不幸的是,目前计算机只能实现对均匀分布的采样,所以就需要有一个策略来通过均匀分布的输入来通过指定的PDF,来求取最终的采样值。

对于路径追踪的应用,已经有比较成熟的重要性采样的分布了,同时通过均匀分布求取采样点的方法也很成熟,下个章节就会说到。

随机数

在实际讨论重要性采样之前,我想先说说随机数,或者说是如何生成均匀分布的随机数,毕竟这是接下来所有采样的数据源头。对于路径追踪的应用而言,我们希望这个随机数尽可能均匀和无偏,所以这里选择的是一个四维Blue Noise(天蓝噪声):

""

如图,蓝噪声分布均匀且频率较高。理论上来讲,要对蓝噪声进行采样,最好使用分层等策略,但这里我图简单,就直接每帧用Math.random()传进来四个随机数,然后加一下算了...

fn getRandom(uv: vec2<f32>, i: i32) -> vec4<f32> {
  let noise: vec4<f32> = textureSampleLevel(u_noise, u_sampler, uv, 0.);

  return fract(material.u_randoms[i] + noise);
}

重要性采样

上一章节提到了重要性采样可以提高蒙特卡洛几分的采样有效性,来加快收敛速度,而重要性采样的核心有二:

  1. 找到合适的分布,求取PDF。
  2. 寻找通过均匀分布转换为该分布的方法。

在路径追踪中,我们一般需要对以下几种状况分别进行采样。接下来就从以上两点,论述这几种状况。

采样部分的代码都在buildin/shaders/sample.chuck.wgsl内。

漫反射半球采样

漫反射需要的是在半球空间内均匀采样,这里采用的是Cosine-Weighted Hemisphere Sampling,即先进行均匀的圆盘采样,然后转换到半球空间:

// 圆盘采样
fn sampleCircle(pi: vec2<f32>) -> vec2<f32> {
  let p: vec2<f32> = 2.0 * pi - 1.0;
  let greater: bool = abs(p.x) > abs(p.y);
  var r: f32;
  var theta: f32;

  if (greater) {
    r = p.x;
    theta = 0.25 * PI * p.y / p.x;
  } else {
    r = p.y;
    theta = PI * (0.5 - 0.25 * p.x / p.y);
  }

  return r * vec2<f32>(cos(theta), sin(theta));
}

// 半球采样
fn cosineSampleHemisphere(p: vec2<f32>) -> vec3<f32> {
  let h: vec2<f32> = sampleCircle(p);
  let z: f32 = sqrt(max(0.0, 1.0 - h.x * h.x - h.y * h.y));

  return vec3<f32>(h, z);
}

// 最终生成漫反射部分的入射光向量
fn calcDiffuseLightDir(basis: mat3x3<f32>, sign: f32, random: vec2<f32>) -> vec3<f32> {
  return basis * sign * cosineSampleHemisphere(random);
}

输入的random是一个均匀分布的随机向量,由前面章节论述的随机数提供,sign是后续要提到的一个法线的符号,而basis,顾名思义是通过法线生成的一个变换矩阵,用于cosineSampleHemisphere生成的本地单位空间的向量,转换到世界空间的法线半球内,这里使用的是pixar采用的一种算法

fn orthonormalBasis(normal: vec3<f32>) -> mat3x3<f32> {
  var zsign: f32 = 1.;
  if (normal.z < 0.) {
    zsign = -1.;
  }
  let a: f32 = -1.0 / (zsign + normal.z);
  let b: f32 = normal.x * normal.y * a;
  let s: vec3<f32> = vec3<f32>(1.0 + zsign * normal.x * normal.x * a, zsign * b, -zsign * normal.x);
  let t: vec3<f32> = vec3<f32>(b, zsign + normal.y * normal.y * a, -normal.y);

  return mat3x3<f32>(s, t, normal);
}

这种分布的PDF为:

$$\frac{cos\theta}{\pi}$$

其中角度为法线和入射光线的夹角。

高光GGX采样

高光反射比较复杂,业界提出过的模型也比较多,这里最终采用的是目前工业界比较通用的GGX分布:

fn calcSpecularLightDir(basis: mat3x3<f32>, ray: Ray, hit: HitPoint, random: vec2<f32>) -> vec3<f32> {
  let phi: f32 = PI * 2. * random.x;
  let alpha: f32 = hit.pbrData.alphaRoughness;
  let cosTheta: f32 = sqrt((1.0 - random.y) / (1.0 + (alpha * alpha - 1.0) * random.y));
  let sinTheta: f32 = sqrt(1.0 - cosTheta * cosTheta);
  let halfVector: vec3<f32> = basis * hit.sign * vec3<f32>(sinTheta * cos(phi), sinTheta * sin(phi), cosTheta);

  return reflect(ray.dir, halfVector);
}

由于是高光反射,所以分布和法线相关很合理。而这种分布的PDF为:

$$\frac{D \vec N \cdot \vec H}{4\vec L \cdot \vec H}$$

其中D是法线分布函数,N是法线,L是入射光线,H是半程向量。

直接光和间接光

在工程实现中,我们并非在路径追踪的每次迭代中直接求出入射光线,然后反向追踪,看其和物体还是光源相交。虽然在样本足够多的情况下得到的结果仍然是无偏的,但出于尽可能加快收敛速度的考量,我们需要思考如下事实:

对于某一着色点,光源往往是光的主要来源,提供了最多的能量,而从其他物体反射而来的能量则相对较少。

这里有两种解法,第一种是找到一个合适的分布,来使得采样采到光源的概率增加,但显然由于场景的动态性,这种分布十分难以找到。所以我们往往使用第二种方法——对渲染方程中的Li进行拆分,对光源单独采样:

$$\int_{\xi^{2}}f_{r}(p,w_{i},w_{o})L_{e}cos{\theta}dw_{i} + \int_{\xi^{2}}f_{r}(p,w_{i},w_{o})L_{s}cos{\theta}dw_{i}$$

如公式,我们将渲染方程的积分部分拆成了两个部分,第一个部分为直接光照,表示对光源的直接采样;第二个部分为间接光照,表示对其他部分的采样。间接光照的采样其实就是上面说的两种,而直接光照的采样,就是对面光源的采样。

面光源采样

本项目中我一共使用了discrect两种面光源,它们的采样和PDF本身都相对简单:

var samplePoint2D: vec2<f32>;
// 求出光源平面世界空间的法线
let normal: vec3<f32> = normalize((areaLight.worldTransform * vec4<f32>(0., 0., -1., 0.)).xyz);
var area: f32;
if (areaLight.areaMode == LIGHT_AREA_DISC) {
  // `disc`光源,进行圆盘采样并求得面积
  samplePoint2D = sampleCircle(random) * areaLight.areaSize.x;
  area = 2. * PI * areaLight.areaSize.x;
} else {
  // `rect`光源,可以非常简单得通过均匀分布变换采样而来,并求得面积
  samplePoint2D = (random) * areaLight.areaSize;
  area = samplePoint2D.x * samplePoint2D.y;
  samplePoint2D = samplePoint2D * 2. - areaLight.areaSize;
}

// 将采样点变换到世界空间,并求出光线向量
let samplePoint: vec4<f32> = areaLight.worldTransform * vec4<f32>(samplePoint2D.x, samplePoint2D.y, 0., 1.);
var sampleDir: vec3<f32> = samplePoint.xyz - hit.position;

// 算出光源平面和光线方向的夹角余弦
let cosine: f32 = dot(normal, sampleDir);
if (cosine > 0.) {
  // 证明光线是从光源背面而来,没有光照
  return vec3<f32>(0.);
}

// 通过投影面积和长度,求得最后的PDF
let maxT: f32 = length(sampleDir);
let pdf: f32 = maxT * maxT / (area * -cosine);
let directLight: vec3<f32> = areaLight.color.rgb / pdf;

可见在整个运算过程中,采样是比较简单的,最后PDF的求取中我们依靠了距离和投影面积,因为计算中有一个从本地空间投影到法线半球空间的过程。

环境光采样

理论上我们也需要支持环境光,环境光一半来自于一个环境贴图,或者说一般是是天空盒。对于环境贴图的采样,同样存在尽可能采样光源部分的问题,但这里我没做,有兴趣的同学可以自己查阅资料。对于环境贴图,我的做法就是很无脑得直接采样纹理颜色。

工程实现

原理到这就论述得差不多了,接下来是实际的工程实现。首先我们要给整个着色过程定义个框架。

光照部分的代码都在buildin/shaders/lighting.chuck.wgsl内。

框架

着色的过程从前面文章提到的GBuffer开始,GBuffer中存储了可以认为是第一次射线检测到的着色点的世界坐标,通过它可以很简单得重建出第一条射线:

let gBInfo: HitPoint = getGBInfo(baseIndex);

if (gBInfo.isLight) {
  textureStore(u_output, baseIndex, vec4<f32>(gBInfo.baseColor, 1.));
  return;
}

if (!gBInfo.hit) {
  // 处理没有被光栅化的像素,即等价为第一条射线没有相交的三角形
  // 直接采样环境贴图返回
  let t: vec4<f32> = global.u_skyVP * vec4<f32>(baseUV.x * 2. - 1., 1. - baseUV.y * 2., 1., 1.);
  let cubeUV: vec3<f32> = normalize(t.xyz / t.w);
  let bgColor: vec4<f32> = textureSampleLevel(u_envTexture, u_sampler, cubeUV, 0.);
  // rgbd
  textureStore(u_output, baseIndex, vec4<f32>(bgColor.rgb / bgColor.a * global.u_envColor.rgb, 1.));
  return;
}

let worldRay: Ray = genWorldRayByGBuffer(baseUV, gBInfo);

这是第一条出射光线,通过这个光线便可以进入实际的着色流程,然后不断反射、直接光照和间接光照,最后终止。这个过程中有两点需要注意:

终止条件

路颈追踪是理论上可以是一个无穷的过程,直到射线和场景不相交为止,但我们显然不能让其一直进行。在实际的测试中,一般每条光线进行七八次bounce(弹射)后就可以认为已经到极限了,每次bounce都是一次间接光照。对于实时而言,在不考虑透射的情况下,我们往往只会进行一次bounce,这意味着在最终效果中我们只会体现出一次的间接光照,从能量守恒的角度这自然是有瑕疵的,但效果也比传统得好得多。

由此来看,场景越亮、层次越丰富就代表越真实、效果越好。

当然如果不考虑这么苛刻的实时性,一般还有像是俄罗斯轮盘赌这样的策略来达成终止条件,也就是通过随机性来判定是否终止。

射线原点

在每次bounce过程中,我们都会生成一个由特定原点发出、到特定方向的射线。但由于浮点数运算精度等问题,如果只是刚好以着色点重心插值后的世界坐标为起点,可能会产生锯齿、乱纹等现象,这一般是由于不该自相交的时候自相交(比如普通反射)或该自相交的时候不自相交(比如阴影射线)。所以需要人为给射线原点加上一个偏移:

hit.position + light.next.dir * RAY_DIR_OFFSET + RAY_NORMAL_OFFSET * hit.normal

可见其实就是在法线方向、以及射线方向加了个微小的偏移。

光照累计

路径追踪是迭代累加的,在每次bounce的过程中,我们总是计算当前着色点的直接光照,然后计算间接光照的系数fr,而间接光照的能量部分则来自于下一次bounce,这就可以很清晰得得出一个计算步骤:

fn traceLight(startRay: Ray, gBInfo: HitPoint, baseUV: vec2<f32>, debugIndex: i32) -> vec3<f32> {
  var light: Light = calcLight(startRay, gBInfo, baseUV, 0, false, false, debugIndex);
  var lightColor: vec3<f32> = light.color;
  var throughEng: vec3<f32> = light.throughEng;
  var hit: HitPoint;
  var ray: Ray = light.next;
  var lightHited: vec4<f32>;
  var bounce: i32 = 0;

  loop {
    let preIsGlass = hit.isGlass;
    lightHited = hitTestLights(ray);
    hit = hitTest(ray);
    let isHitLight: bool = lightHited.a <= hit.hited;
    let isOut: bool = !hit.hit || isHitLight;

    if (isHitLight) {
      light.color = lightHited.rgb;
    } else {
      light = calcLight(ray, hit, baseUV, bounce, isLast, isOut);
      ray = light.next;
    }

    lightColor = lightColor + light.color * throughEng;
    throughEng = throughEng * light.throughEng;
    bounce = bounce + 1;

    if (max(throughEng.x, max(throughEng.y, throughEng.z)) < 0.01 || isOut) {
      break;
    }
  }

  return lightColor;
}

其中最值得关注的是lightColorthroughEng,第一个是当前直接光照的结果,第二个是出射(未被吸收)的能量比例。可见这里认为如果检测到了和光源相交,则直接累计,否则而进入光照计算阶段,计算完成后将当前的直接光照结果和累计至今的能量损耗计算,得到应当计入的能量。而光照的计算部分框架实现为:

fn calcLight(ray: Ray, hit: HitPoint, baseUV: vec2<f32>, bounce: i32, isLast: bool, isOut: bool) -> Light {
  var light: Light;
  // 计算随机数
  let random = getRandom(baseUV, bounce);

  if (isOut) {
    // 如果射线和场景无相交,则进行环境光照
    light.color = calcOutColor(ray, hit);
    return light;
  }

  // 直接光照
  light.color = calcDirectColor(ray, hit, random.zw);

  if (isLast) {
    // 最后一次测试,不再进行间接光照计算
    return light;
  }

  // 间接光照过程
  let probDiffuse: f32 = getDiffuseProb(hit);
  let isDiffuse: bool = random.z < probDiffuse;
  let nextDir: vec3<f32> = calcBrdfDir(ray, hit, isDiffuse, random.xy);

  if (isDiffuse) {
    light.throughEng = calcDiffuseFactor(ray, hit, nextDir.xyz, probDiffuse);
  } else {
    light.throughEng = calcSpecularFactor(ray, hit, nextDir.xyz, probDiffuse);
  }

  light.next = genRay(hit.position + light.next.dir * RAY_DIR_OFFSET + RAY_NORMAL_OFFSET * hit.normal, nextDir.xyz);
  return light;
}

可见光照计算切实被分为了直接光照间接光照环境光照(没有做特别处理,直接采样贴图)三部分。有了基本的框架,就可以进行真正的光照计算了。

直接光照

当射线和表面上的着色点相交后,首先要进行的是直接光照计算。这里分两步,第一步需要对光源进行采样,第二部则是用这个采样的结果进行计算。采样的部分在上面已经论述过(得到了入射光的方向和辐射度),接下来就是利用采样的结果进行fr的计算:

fn calcDirectColor(ray: Ray, hit: HitPoint, random: vec2<f32>) -> vec3<f32> {
  // 同上面光源采样部分的代码,求得了入射光方向`sampleLight`、距离`maxT`和辐射度`directLight`等

  let shadowInfo: FragmentInfo = hitTestShadow(sampleLight, maxT);

  if (shadowInfo.hit) {
    return vec3<f32>(0.);
  }

  let brdf = calcBrdfDirectOrSpecular(hit.pbrData, hit.normal, -ray.dir, sampleDir, true, 0.);

  return directLight * brdf;
}

可见这里主要有两步计算,第一步是进行阴影射线相交测试,这个在前面的文章《BVH与射线场景求交插值》中论述过,是为了判断光源和着色点之间是否有物体遮挡,如果有遮挡则证明在阴影范围内,直接返回。否则就进入下一步——计算fr,即代码中的brdf

直接光照的fr使用BRDF的高光反射模型。

间接光照

由于高光反射也被间接光照使用,所以在合理先论述间接光照。间接光照由漫反射和高光反射两部分构成,有了着色点和出射光线的信息后,可以按照上面章节论述的采样方式分别求得漫反射和高光反射的入射光线,但这里要注意一点,那就是比例。在框架一节的代码中,有这么一句话:

let probDiffuse: f32 = getDiffuseProb(hit);

这求取的是漫反射的比例,还记得一开始的BRDF方程中的kdks吗?其实就是它。在工程实现中,为了效率,我们往往不会在同一次bounce计算漫反射和高光反射将它们加权相加,而是将这个比例作为概率,来作为当次要进行漫反射还是高光反射的判据,而它的实现为:

fn getDiffuseProb(hit: HitPoint) -> f32 {
  let lumDiffuse: f32 = max(.01, dot(hit.pbrData.diffuseColor, vec3<f32>(0.2125, 0.7154, 0.0721)));
  let lumSpecular: f32 = max(.01, dot(hit.pbrData.specularColor, vec3<f32>(0.2125, 0.7154, 0.0721)));

  return lumDiffuse / (lumDiffuse + lumSpecular);
}

计算中分别求得了着色点的名为diffuseColorspecularColor的变量的亮度,然后算出了漫反射的权重。那么这两个变量又是如何求得的呢?这就涉及到PBR材质的处理了。

材质预处理

在前面的文章,我们了解了使用的材质模型,其中有金属和高光两种工作流,有不同的系数和贴图,现在我们需要将其处理为BRDF需要的参数:

fn pbrPrepareData(
  isSpecGloss: bool,
  baseColor: vec3<f32>,
  metal: f32, rough: f32,
  spec: vec3<f32>, gloss: f32
) -> PBRData {
  var pbr: PBRData;

  var specularColor: vec3<f32>;
  var roughness: f32;

  if (!isSpecGloss) {
    // 金属工作流
    roughness = clamp(rough, 0.04, 1.0);
    let metallic: f32 = clamp(metal, 0.0, 1.0);
    let f0: vec3<f32> = vec3<f32>(0.04, 0.04, 0.04);

    specularColor = mix(f0, baseColor, vec3<f32>(metallic));
    pbr.diffuseColor = (1.0 - metallic) * (baseColor * (vec3<f32>(1.0, 1.0, 1.0) - f0));
  }
  else {
  // 高光工作流
    roughness = 1.0 - gloss;
    specularColor = spec;
    pbr.diffuseColor = baseColor * (1.0 - max(max(specularColor.r, specularColor.g), specularColor.b));
  }

  pbr.baseColor = baseColor;
  pbr.specularColor = specularColor;
  pbr.roughness = roughness;

  let reflectance: f32 = max(max(specularColor.r, specularColor.g), specularColor.b);
  pbr.reflectance90 = vec3<f32>(clamp(reflectance * 25.0, 0.0, 1.0));
  pbr.reflectance0 = specularColor.rgb;
  pbr.alphaRoughness = roughness * roughness;

  return pbr;
}

这个方法完成了材质数据的预处理,其主要通过两种工作流,统一计算出了粗糙度、漫反射颜色、高光反射颜色、菲涅尔系数等等。这里可以看到比如金属工作流和高光工作流的差异,主要在于高光工作流可以自己控制f0,而金属工作流可以由金属度来控制漫反射比例(完全金属没有漫反射)。

高光反射

有了材质数据,可以先来求高光反射。我实现了一个方法,用于求解高光反射,针对直接光照间接光照的高光部分做了区分,但大部分计算都是一致的,区分主要在PDF部分。

fn calcBrdfDirectOrSpecular(
  pbr: PBRData, normal: vec3<f32>,
  viewDir: vec3<f32>, lightDir: vec3<f32>,
  isDirect: bool, probDiffuse: f32
)-> vec3<f32> {
  let H: vec3<f32> = normalize(lightDir + viewDir);
  let NdotV: f32 = clamp(abs(dot(normal, viewDir)), 0.001, 1.0);
  let NdotL: f32 = clamp(abs(dot(normal, lightDir)), 0.001, 1.0);
  let NdotH: f32 = clamp(abs(dot(normal, H)), 0.0, 1.0);
  let LdotH: f32 = clamp(abs(dot(lightDir, H)), 0.0, 1.0);
  let VdotH: f32 = clamp(dot(viewDir, H), 0.0, 1.0);
  // Calculate the shading terms for the microfacet specular shading model
  let F: vec3<f32> = pbrSpecularReflection(pbr.reflectance0, pbr.reflectance90, VdotH);
  let G: f32 = pbrGeometricOcclusion(NdotL, NdotV, pbr.alphaRoughness);
  let D: f32 = pbrMicrofacetDistribution(pbr.alphaRoughness, NdotH);

  let specular: vec3<f32> = F * G * D / (4.0 * NdotL * NdotV);

  if (isDirect) {
    let diffuse: vec3<f32> = NdotL * INV_PI * pbr.diffuseColor;
    return specular + diffuse;
  }

  let specularPdf: f32 = D * NdotH / (4.0 * LdotH);
  return NdotL * specular / (mix(specularPdf, 0., probDiffuse));
}

在求解的实现中,先不看PDF部分,主要计算的是specular,而其最主要的是菲涅尔项F、法线分布项D和几何遮蔽项G的实现。

F

菲涅尔项的求解实现为:

fn pbrSpecularReflection(reflectance0: vec3<f32>, reflectance90: vec3<f32>, VdotH: f32)-> vec3<f32> {
  return reflectance0 + (reflectance90 - reflectance0) * pow(clamp(1.0 - VdotH, 0.0, 1.0), 5.0);
}

这里采用的是Schilick近似方法。

D

法线分布我们采用的常见的GGX分布:

fn pbrMicrofacetDistribution(alphaRoughness: f32, NdotH: f32)-> f32 {
  let roughnessSq: f32 = alphaRoughness * alphaRoughness;
  let f: f32 = NdotH * NdotH * (roughnessSq - NdotH) + 1.0;
  return roughnessSq * INV_PI / (f * f);
}

G

几何遮蔽则使用Smith GGX近似:

fn pbrGeometricOcclusion(NdotL: f32, NdotV: f32, alphaRoughness: f32)-> f32 {
  let r: f32 = alphaRoughness * alphaRoughness;

  let attenuationL: f32 = 2.0 * NdotL / (NdotL + sqrt(r + (1.0 - r) * (NdotL * NdotL)));
  let attenuationV: f32 = 2.0 * NdotV / (NdotV + sqrt(r + (1.0 - r) * (NdotV * NdotV)));
  return attenuationL * attenuationV;
}

PDF

如果是直接光照,那么直接返回这个高光specular的值即可(因为直接光照的PDF已经在前面的计算除过了),但是对于间接光照,还是需要考虑PDF,以及漫反射的概率。前面提到过我们对高光使用的是GGX分布,其PDF公式翻译为代码为:

let specularPdf: f32 = D * NdotH / (4.0 * LdotH);

而在考虑漫反射概率后,其最终值为:

specular * NdotL / (mix(specularPdf, 0., probDiffuse));

漫反射

相对于高光,漫反射比较简单,其值根据原理所得即为hit.pbrData.diffuseColor * INV_PI,而PDF则为:

let diffusePdf: f32 = NdotL * INV_PI;

考虑到概率,最后的值为:

hit.pbrData.diffuseColor * INV_PI * NdotL / diffusePdf / probDiffuse;

化简后为:

hit.pbrData.diffuseColor / probDiffuse;

结果

至此,整个着色过程完成,由于是1SPP,并且只有一次bounce,在单帧着色后会得到一张充满噪点的结果:

""

那么接下来就自然要想到如何降噪了,下一文章将会提到。

【WebGPU实时光追美少女】BVH与射线场景求交插值

作者 dtysky
2021年9月26日 08:10

本系列文章设计的所有代码均已开源,Github仓库在这里:dtysky/webgpu-renderer

在上一篇文章管线组织与GBuffer中,我提到了路径追踪的第一步是射线和场景的求交,进一步引出了BVH的概念。在这一章,我将会论述如何利用BVH对场景中的所有三角形进行高效得分割和求交,并且给出得出交点后属性插值的方法。

不知道为何最近有点抑郁,所以更新效率低了一些...

求交部分的代码都在buildin/shaders/ray-tracing/hitTest.chunk.wgsl内。

射线和场景求交

首先要讨论的是射线和场景的求交,或者用在物理引擎中的叫法碰撞测试hitTest。我们并不需要先论述BVH这样的加速结构,因为无论哪种加速结构,到最后总是要进行射线和三角形的求交和插值。

射线和三角形描述

为了求交计算,需要先对射线和三角形进行描述。首先是射线,描述一条射线有很多种方式,这里使用最便于GPU计算的结构:

struct Ray {
  origin: vec3<f32>;
  dir: vec3<f32>;
  invDir: vec3<f32>;
};

通过一个原点origin和方向dir来使用方程p = origin + t * dir描述射线,invDir是方向向量的倒数,便于后续使用。有了射线的结构后,考虑如何生成射线,但这一部分我将放在下一章来讲。

接下来是三角形,在前面的文章我论述过如何将整个场景的数据进行合并,其中就包括了顶点数据indexData的合并。一个三角形由三个顶点描述,所以只需要知道三角形三个顶点的索引即可。这里先不讨论BVH,所以就认为我们是在顺序、每三个顶点遍历整个indexData即可。得到顶点索引后,便可以直接从storage buffer中获得对应的顶点属性,比如u_positions.value[index0]

求交

有了射线和三角形的信息,便可以进行求交计算。射线和三角形的求交思路不止一种,一个最简单和直观的思路为:

  1. 先求射线和三角形所在平面的交点。
  2. 再判断交点是否在三角形内部。

但由于需要首先确定三角形所在平面,这么做实际运算效率其实不高,实际上我们可以直接通过方程来描述三角形,和射线方程联立整理,来简化计算:

""

  1. 空间射线方程:P = O + t * D
  2. 空间三角形方程:P = u * E1 + v * E2,其中E1 = V1 - V0E2 = V2 - V0是两条边的向量,uv是权重,V1V2V3是三个顶点。

联立后得到方程:O + t * D = u * E1 + v * E2,这是一个三元方程组,自然可以化简写成矩阵形式:

[-D, E1, E2] * [t, u, v] = T,其中T = O - V0[-D, E1, E2]是3x3列主序矩阵,[t, u, v]是转置(赖得打公式了...

而经过克莱姆法则和混合积公式推导,记det = D x E2 · E1最后可写为:

t = (T x E1 * E2) / det
u = (D x E2 * T) / det
v = (T x E1 * E2) / det

如此一来,便可以求出这三个系数,并在求取的过程中顺便将不满足条件的情况判断出来:

// 判断是否相交,如果相交,返回所有的顶点属性
fn triangleHitTest(ray: Ray, leaf: BVHLeaf) -> FragmentInfo {
  var info: FragmentInfo;
  let indexes: vec3<u32> = leaf.indexes;
  // 索引出三个顶点
  info.p0 = u_positions.value[indexes[0]];
  info.p1 = u_positions.value[indexes[1]];
  info.p2 = u_positions.value[indexes[2]];

  let e1: vec3<f32> = info.p1 - info.p0;
  let e2: vec3<f32> = info.p2 - info.p0;
  let p: vec3<f32> = cross(ray.dir, e2);
  var det: f32 = dot(e1, p);
  var t: vec3<f32> = ray.origin - info.p0;

  // 保证`det`为正
  if (det < 0.) {
    t = -t;
    det = -det;
  }

  // `det`为0,三向量共面,算作不相交
  if (det < 0.0001) {
    return info;
  }

  let u: f32 = dot(t, p);

  // `u / det`必须在(0,1)
  if (u < 0. || u > det) {
    return info;
  }

  let q: vec3<f32> = cross(t, e1);
  let v: f32 = dot(ray.dir, q);

  // `v / det`和`(1 - v - u) / det`必须在(0,1)
  if (v < 0. || v + u - det > 0.) {
    return info;
  }

  let lt: f32 = dot(e2, q);

  // lt即为射线方程的`t`,由于是射线,其必须大于0
  if (lt < 0.) {
    return info;
  }

  let invDet: f32 = 1. / det;
  info.weights = vec3<f32>(0., u, v) * invDet;
  info.weights.x = 1. - info.weights.y - info.weights.z;

  info.hit = true;
  info.t = lt * invDet;
  info.hitPoint = ray.origin + ray.dir * info.t;
  info.uv0 = u_uvs.value[indexes.x];
  info.uv1 = u_uvs.value[indexes.y];
  info.uv2 = u_uvs.value[indexes.z];
  info.n0 = u_normals.value[indexes.x];
  info.n1 = u_normals.value[indexes.y];
  info.n2 = u_normals.value[indexes.z];
  info.meshIndex = u_meshMatIndexes.value[indexes.x].x;
  info.matIndex = u_meshMatIndexes.value[indexes.x].y;
  let metallicRoughnessFactorNormalScaleMaterialType: vec4<f32> = material.u_metallicRoughnessFactorNormalScaleMaterialTypes[info.matIndex];
  info.matType = u32(metallicRoughnessFactorNormalScaleMaterialType[3]);
}

如果这些测试都通过,则射线和三角形相交,而我们也顺便得到了uvdet,通过它们,也就顺便获得了重心坐标。于此同时,我还将属于这个三角形的三个顶点属性都存了下来,以供下一个阶段做插值。

重心坐标插值

重心坐标插值这个概念在传统光栅化渲染器的栅格化一步也会提到,即当某个像素被一个平面三角形覆盖时,需要通过三角形的三个顶点属性算出当前像素实际的顶点属性。而对于路径追踪渲染器,由于同样是用三角形描述场景,所以也需要这么一个插值的过程,来计算出交点实际的顶点属性值。

所谓重心坐标,就是上面的info.weights。对于一个三角形内的点,它们都在(0,1)之间,记录了三角形的每个顶点到交点的距离的比例,从这个角度而言,它们实际上也是这个交点相对于三角形三个顶点的权重

// 通过相交点的参数,求取最终插值后的结果并返回
fn fillHitPoint(frag: FragmentInfo, ray: Ray) -> HitPoint {
  var info: HitPoint;

  info.hit = true;
  info.hited = frag.t;
  info.meshIndex = frag.meshIndex;
  info.matIndex = frag.matIndex;
  let metallicRoughnessFactorNormalScaleMaterialType: vec4<f32> = material.u_metallicRoughnessFactorNormalScaleMaterialTypes[frag.matIndex];
  info.position = frag.p0 * frag.weights[0] + frag.p1 * frag.weights[1] + frag.p2 * frag.weights[2];
  let uv: vec2<f32> = frag.uv0 * frag.weights[0] + frag.uv1 * frag.weights[1] + frag.uv2 * frag.weights[2];
  let textureIds: vec4<i32> = material.u_matId2TexturesId[frag.matIndex];  
  let faceNormal: vec3<f32> = getFaceNormal(frag);
  info.sign = sign(dot(faceNormal, -ray.dir));
  info.normal = info.sign * getNormal(frag, uv, textureIds[1], metallicRoughnessFactorNormalScaleMaterialType[2]);
  let baseColor: vec4<f32> = getBaseColor(material.u_baseColorFactors[frag.matIndex], textureIds[0], uv);
  info.baseColor = baseColor.rgb;
  info.glass = baseColor.a;
  info.matType = frag.matType;
  info.isSpecGloss = isMatSpecGloss(frag.matType);
  info.isGlass = isMatGlass(frag.matType);

  if (info.isSpecGloss) {
    let specularGlossinessFactors: vec4<f32> = material.u_specularGlossinessFactors[frag.matIndex];
    info.spec = getSpecular(specularGlossinessFactors.xyz, textureIds[2], uv).rgb;
    info.gloss = getGlossiness(specularGlossinessFactors[3], textureIds[2], uv);
  } else {
    info.metal = getMetallic(metallicRoughnessFactorNormalScaleMaterialType[0], textureIds[2], uv);
    info.rough = getRoughness(metallicRoughnessFactorNormalScaleMaterialType[1], textureIds[2], uv);
  }

  info.pbrData = pbrPrepareData(info.isSpecGloss, info.baseColor, info.metal, info.rough, info.spec, info.gloss);

  return info;
}

以上是插值、求得最后结果的逻辑,可以见到像是positionuv之类的顶点属性都使用了重心坐标进行插值,而后也是用插值后的结果来采样贴图、计算材质属性的。

加速结构

如果不考虑性能,理论上上面的算法已经足够满足需求,我们只需要在每次发射射线的时候用该射线和场景内所有三角形求交,得到所有相交的点,然后找到其中离射线原点最近的即可。然而这是不现实的,在当前的这个时代,一个场景有上万上十万个三角形都挺正常,在实时的情况下即便是1SPP开销都是巨大的,所以这就需要我们有办法来加速整个求交的过程,也就引出了接下来要论述的——加速结构。

常见加速结构

加速结构的本质在于场景划分,通过合适的算法划分场景,来达到索引优化,最终减少真正的计算。场景划分不仅仅用于路径追踪,实际上在传统的实时渲染中也有很大的作用,比如ForwardAdd中的多光源实现等等。

""

如图,常见的加速结构有八叉树、KD树、BSP树等等。但他们都属于空间划分,而空间划分对于我们的场景有很大的劣势:

  1. 物体可能存在与多个划分区域,划分不彻底。
  2. 为了建立正确的划分,需要求取区域和三角形的相交。

所以在实时光追的引擎中,大家一般都使用BVH,即层次包围盒:

""

如图,这种结构不针对空间,而是根据物体的层次来对场景做划分。这样可以保证每个三角形都存在于唯一的划分区域内,同时整棵树比较平衡、最大深度低,而且构建十分简单。但当然世上没有免费的优化,其代价为:

  1. 由于不是空间划分,所以包围盒之间可能重叠,必须完全遍历整棵树才能得到最终结果,某些情况下相对求交效率可能低一些。
  2. 虽然构建效率高,但结构变了需要完全重建。不过这一部分已经被现代算法优化得很好了,有GPU算法(但本文不会提及)。

不过相对来讲,BVH仍然是当下最适合实时路径追踪的加速结构。

构造BVH

构造BVH可以在CPU进行,也可以在GPU进行(使用莫顿码),但由于个人精力有限,所以这里只实现了传统CPU的版本。

CPU部分构造BVH的代码全部在extension/BVH.ts内。

这里我不会讲所有的构建的代码都事无巨细列出来,只会说一些思路,具体的可以参考实际代码。

简单来讲,为了构建一个BVH,我们至少需要三步:

  1. 通过当前合并好的三角形,求取每个三角形的包围盒(世界空间)。
  2. 通过这些包围盒,构建二叉树,树的叶子节点为实际的三角形信息,其他为包围盒信息。
  3. 将树状结构打平为紧密的数组来存储。

所以我也讲这三步实现为了三个方法。

求取包围盒

首先需要一个数据结构来描述包围盒,其声明为:

export class Bounds {
  // 最大边界
  public max: Float32Array;
  // 最小边界
  public min: Float32Array;
  // 中点
  get center(): Float32Array;
  // 尺寸
  get size(): number;
  // 长度最大的轴
  get maxExtends(): EAxis;
  // 包围盒的表面积
  get surfaceArea(): number ;

  // 生成一个空的包围盒
  public initEmpty(): Bounds;
  // 从顶点生成一个包围盒
  public fromVertexes(v1: Float32Array, v2: Float32Array, v3: Float32Array): Bounds;
  // 扩张一个包围盒
  public update(v: Float32Array): Bounds;
  // 合并另一个包围盒,求并
  public mergeBounds(bounds: Bounds): Bounds;
  // 一个点是否在包围盒内
  public pointIn(p: Float32Array): boolean;
  // 获取一个点与包围盒某个轴最小值的偏移比例,一般0~1
  public getOffset(axis: EAxis, v: Float32Array): number;
}

这个类也是接下来所有操作的基础。有了它,便可以很简单地求得所有三角形的包围盒了:

protected _setupBoundsInfo = (worldPositions: Float32Array, indexes: Uint32Array) => {
  this._boundsInfos = [];

  for (let i = 0; i < indexes.length; i += 3) {
    const idxes = indexes.slice(i, i + 3);

    // 从`worldPositions`中解出每个顶点需要的数据
    copyTypedArray(3, tmpV1, 0, worldPositions, idxes[0] * 4);
    copyTypedArray(3, tmpV2, 0, worldPositions, idxes[1] * 4);
    copyTypedArray(3, tmpV3, 0, worldPositions, idxes[2] * 4);

    // 构建包围盒
    const bounds = new Bounds().fromVertexes(tmpV1, tmpV2, tmpV3);
    // 用于构建树的信息,包括包围盒本身,以及对应的三角形顶点索引
    this._boundsInfos.push({indexes: idxes, bounds});
  }
}

构建二叉树

有了包围盒后,便可以利用它们构建二叉树,构建BVH树的基本原理非常简单,首先我们要定义两个数据结构:结点BVHNode和叶子BVHLeaf

export interface IBVHNode {
  // 沿哪个轴划分
  axis: EAxis;
  // 所有子节点合并的包围盒
  bounds: Bounds;
  // 左右子节点
  child0: IBVHNode | IBVHLeaf;
  child1: IBVHNode | IBVHLeaf;
  // 结点在树中的深度(层数)
  depth: number;
}

// 用于存储数个按策略不可划分的三角形
export interface IBVHLeaf {
  // 包含三角形的起始索引
  infoStart: number;
  // 包含三角形的结束索引
  infoEnd: number;
  // 所有子节点的包围盒
  bounds: Bounds;
}

最基础的BVH的构建本质上是一个递归,自顶而下不断二分,直到无法继续分割。同时在这个过程中将所有子节点的包围盒合并到父节点,最终无法分割的节点记为叶子节点BVHLeaf:

protected _buildRecursive(start: number, end: number, depth: number): IBVHNode | IBVHLeaf {
  const {_boundsInfos} = this;
  const bounds = new Bounds().initEmpty();
  // 合并所有子节点的包围盒
  for (let i = start; i < end; i += 1) {
    bounds.mergeBounds(_boundsInfos[i].bounds);
  }

  const nPrimitives = end - start;
  if (nPrimitives <= this._maxPrimitivesPerLeaf) {
    // 如果当前三角形数量不可再分割,直接返回叶子节点
    return { infoStart: start, infoEnd: end, bounds };
  } else {
    // 否则,通过一定规则算出新的分割点`mid`,进行下一轮递归
    // 这里还可能执行一些图元顺序的调整,来达到最有分割,分割有不同的策略

    // 递归计算左右子节点
    const child0 = this._buildRecursive(start, mid, depth + 1);
    const child1 = this._buildRecursive(mid, end, depth + 1);

    // 返回新的`BVHNode`
    return {
      bounds: new Bounds().initEmpty().mergeBounds(child0.bounds).mergeBounds(child1.bounds),
      axis: dim, child0, child1, depth
    };
  }
}

可见原理确实很简单,实际上对于这种传统的BVH构建算法,最精髓的在于注释中写道的分割策略。考察一个BVH的构建是否优秀,一般需要评估两点:

  1. 在相同的图元数量下,BVH的层数是否最低。
  2. 每一层两个子节点中的包围盒重叠程度是否足够低。

围绕这两点有各种不同的算法来权衡,在本项目的实现中,使用的是SAH - Surface Area Heuristic,即表面积启发式算法。这是一种从概率角度的优化,从理论上来讲,一条射线和场景求交的代价,主要由和图元即三角形的代价来决定,一次求交中计算的图元数量越多,也就意味着效率越低。而和图元求交的概率,又可以用包含图元的包围盒的重叠程度来表征,进一步可以用包围盒的表面积来表征,最大程度减少包围盒的表面积,就是SAH的目的,所以它实际上是解决了上面的第二个问题。

""

如图,实现上来讲,我们可以先求得所有包围盒中心所构成的最大包围盒,以此包围盒最长的轴作为用于切分的轴。如果包围盒已然不可划分(min=max),则直接返回,接着如果目前图元数量小于一定级别,则使用简单的包围盒中心位置做快速选择nth_element划分;再否则就进行正常的SAH,这里使用了一种基于均分的buckets(桶)的算法)——将图元按照中心位置划分到不同的桶内,再从低到高不断合并这些桶来求得划分代价,接着得出最低的划分代价,最终按照这个最低代价的桶为中点进行划分。实现详见代码。

打平为数组

有了BVH树还不够,因为最后一步要送到CS内进行最后的求交,所以必须要将其组装成一个storage buffer,这就要求我们设计一种紧凑的数据结构,来将整棵树存储到一个ArrayBuffer中。而我设计的结构如下:

  1. 对于Node,使用uint32 x 1来存储子节点的类型(最低位,0为Node,1为Leaf)和偏移,float x 3存储子节点的包围盒,共两个子节点,消耗8个float的长度。
  2. 对于Leaf,使用uint32 x 1来存储接下来有几个三角形属于同一个节点,uint32 x 3存储当前三角形的三个顶点索引。

有了结构,整个Buffer的构建也就比较简单,同样是递归构建:

protected _traverseNode = (
  node: IBVHNode | IBVHLeaf,
  info: {maxDepth: number, nodes: number[], leaves: number[]},
  depth: number = 1,
  parentOffset: number = -1,
  childIndex: number = 0
) => {
  info.maxDepth = Math.max(depth, info.maxDepth);
  const {nodes, leaves} = info;
  const {_boundsInfos, _bvhNodes, _bvhLeaves} = this;

  if (isBVHLeaf(node)) {
    _bvhLeaves.push(node);
    if (parentOffset >= 0) {
      nodes[parentOffset * 8 + childIndex] = (1 << 31) | (leaves.length / 4);
    }

    const count = node.infoEnd - node.infoStart;
    for (let i = node.infoStart; i < node.infoEnd; i += 1) {
      const idxes = _boundsInfos[i].indexes;

      leaves.push(count, idxes[0], idxes[1], idxes[2]);
    }
  } else {
    _bvhNodes.push(node);
    const bounds = node.bounds;
    const nodeOffset = nodes.length / 8;

    if (parentOffset >= 0) {
      nodes[parentOffset * 8 + childIndex] = nodeOffset * 2;
    }

    nodes.push(
      0, bounds.min[0], bounds.min[1], bounds.min[2],
      0, bounds.max[0], bounds.max[1], bounds.max[2]
    );

    this._traverseNode(node.child0, info, depth + 1, nodeOffset, 0);
    this._traverseNode(node.child1, info, depth + 1, nodeOffset, 4);
  }
}

注意这里我们构建好的Buffer正好是16字节对齐的。

BVH求交

构建了BVH后,使用u_bvh作为storage buffer传给CS来进行求交。BVH的求交远离比较简单巧妙,由于BVH本质上是一种AABB,同时和射线也并不需要得到真正的交点,只需要得到是否相交的结果。所以可以将其视为三对互相平行的平板,如图:

""

可以将射线投影到三个平面,最终需要满足最大的xmin - origin小于最小的xmax - origin,这个原理也好理解,如果将前者理解为光线进入盒子的时间,后者理解为光线离开盒子的时间,那么我们最终要保证进入时间小于离开时间

fn boxHitTest(ray: Ray, max: vec3<f32>, min: vec3<f32>) -> f32 {
  let t1: vec3<f32> = (min - ray.origin) * ray.invDir;
  let t2: vec3<f32> = (max - ray.origin) * ray.invDir;
  let tvmin: vec3<f32> = min(t1, t2);
  let tvmax: vec3<f32> = max(t1, t2);
  let tmin: f32 = max(tvmin.x, max(tvmin.y, tvmin.z));
  let tmax: f32 = min(tvmax.x, min(tvmax.y, tvmax.z));

  if (tmax - tmin < 0.) {
    return -1.;
  }

  if (tmin > 0.) {
    return tmin;
  }

  return tmax;
}

以上方法返回的结果如果大于0,则表示射线和BVH相交,可见比起三角形求交,是十分高效的。而其中的maxmin则是通过对数据的decode得来的:

struct Child {
  isLeaf: bool;
  offset: u32;
};

fn decodeChild(index: u32) -> Child {
  return Child((index >> 31u) != 0u, (index << 1u) >> 1u);
}

fn getBVHNodeInfo(offset: u32) -> BVHNode {
  var node: BVHNode;
  let data0: vec4<f32> = u_bvh.value[offset];
  let data1: vec4<f32> = u_bvh.value[offset + 1u];
  node.child0Index = bitcast<u32>(data0[0]);
  node.child1Index = bitcast<u32>(data1[0]);
  node.min = data0.yzw;
  node.max = data1.yzw;

  return node;
}

fn getBVHLeafInfo(offset: u32) -> BVHLeaf {
  var leaf: BVHLeaf;
  let data1: vec4<f32> = u_bvh.value[offset];
  leaf.primitives = bitcast<u32>(data1.x);
  leaf.indexes.x = bitcast<u32>(data1.y);
  leaf.indexes.y = bitcast<u32>(data1.z);
  leaf.indexes.z = bitcast<u32>(data1.w);

  return leaf;
}

综合实现

有了BVH、BVH求交算法、三角形求交算法、插值算法,便可以组装出最终的求交方案。但在这之前,我们需要先明确求交的分类:

  1. 普通射线求交:最普通的求交,用于间接光照,需要找到离射线原点最近的三角形。
  2. 阴影射线求交:阴影射线,用于直接光照,详细在后面文章会讲到,只需要判断射线和一定范围内的三角形有相交。
  3. 光源求交:和光源求交,是和三角形不同的另一种算法,在这里顺便也讲了。

一般来讲,普通求交要和光源求交联合起来,因为如果射线最先相交的是光源,还进行正常的表面计算会出现问题,阴影射线求交理论同理(若只有一个光源可以忽略)。

普通射线

由于要寻找的是离射线原点最近的交点,所以一定是遍历完整棵树才能停下,这可以用一次dfs实现,途中还可以直接剪枝。dfs最简单的是使用递归,但显然我们不能再wgsl里使用递归,所以只能自己维护一个递归栈,用迭代来实现了:

fn hitTest(ray: Ray) -> HitPoint {
  var hit: HitPoint;
  var fragInfo: FragmentInfo;
  // 初始化为最大长度
  fragInfo.t = MAX_RAY_LENGTH;
  var node: BVHNode;
  // 构建BVH深度大小的结点栈,栈中数据为结点索引
  var nodeStack: array<u32, ${BVH_DEPTH}>;
  nodeStack[0] = 0u;
  var stackDepth: i32 = 0;

  loop {
    // 当前深度小于10,栈中已无结点,结束
     if (stackDepth < 0) {
       break;
     }

    let child = decodeChild(nodeStack[stackDepth]);
    stackDepth = stackDepth - 1;

    if (child.isLeaf) {
      // 进行叶子的求交,内部本质上是进行了三角形求交,最终返回求交的信息
      let info: FragmentInfo = leafHitTest(ray, child.offset, false);

      if (info.hit && info.t < fragInfo.t) {
        // 如果交点比当前交点近,则使用替换
        fragInfo = info;
      }

      continue;
    }

    node = getBVHNodeInfo(child.offset);
    // 进行结点的求交
    let hited: f32 = boxHitTest(ray, node.max, node.min);

    if (hited < 0.) {
      continue;
    }

    // 相交,两个子节点入栈,进入下一次迭代
    stackDepth = stackDepth + 1;
    nodeStack[stackDepth] = node.child0Index;
    stackDepth = stackDepth + 1;
    nodeStack[stackDepth] = node.child1Index;
  }

  if (fragInfo.hit) {
    // 如果有交点,进行重心坐标插值,同时生成材质属性
    hit = fillHitPoint(fragInfo, ray);
  }

  return hit;
}

阴影射线

阴影射线求交和普通求交大差不差,唯一的区别在于需要给定一个初始的最大值maxT,即射线原点到光源之上采样点之间的距离,并且判断有相交后即可结束求交,立即返回:

fn hitTestShadow(ray: Ray, maxT: f32) -> FragmentInfo {
  var fragInfo: FragmentInfo;
  var node: BVHNode;
  var nodeStack: array<u32, ${BVH_DEPTH}>;
  nodeStack[0] = 0u;
  var stackDepth: i32 = 0;

  loop {
     if (stackDepth < 0) {
       break;
     }

    let child = decodeChild(nodeStack[stackDepth]);
    stackDepth = stackDepth - 1;

    if (child.isLeaf) {
      let info: FragmentInfo = leafHitTest(ray, child.offset, true);

      if (info.hit && info.t < maxT && info.t > EPS) {
        // 判断在射线原点和光源采样点之间有遮挡,返回
        return info;
      }

      continue;
    }

    node = getBVHNodeInfo(child.offset);
    let hited: f32 = boxHitTest(ray, node.max, node.min);

    if (hited < 0.) {
      continue;
    }

    stackDepth = stackDepth + 1;
    nodeStack[stackDepth] = node.child0Index;
    stackDepth = stackDepth + 1;
    nodeStack[stackDepth] = node.child1Index;
  }

  return fragInfo;
}

光源

最后就是和光源的求交了,早在前面的文章WebGPU基础与简单渲染器中,我们就提到了本引擎是如何描述光源,尤其是面光源的:

struct LightInfo {
  lightType: u32;
  areaMode: u32;
  areaSize: vec2<f32>;
  color: vec4<f32>;
  worldTransform: mat4x4<f32>;
  worldTransformInverse: mat4x4<f32>;
};

而对于面光源(无论是什么形状),一个通用的做法是先求取射线和平面的交点,然后判断交点是否在光源内,这个也就是本文一开始提到的、低效的三维平面求交。但这里好在引擎已经提供了光源的worldTransformInverse,所以理论上我们可以认为面光源总是在自己的本地空间(XZ平面),此时只要反向将射线变换到光源的本地空间,问题就变得简单了很多:

  1. 求取射线与平面的交点被简化了,最终得到的是一个XZ平面的点。
  2. 判定交点和光源的关系被简化到了二维空间。

目前引擎支持discrect两种面光源,在平面上可以非常简单得判定包含关系:

fn hitTestXZPlane(ray: Ray, inverseMat: mat4x4<f32>) -> vec3<f32> {
  // 射线方向变换到光源本地空间
  let invDir: vec3<f32> = normalize((inverseMat * vec4<f32>(ray.dir, 0.)).xyz);
  // 光源本地空间法线的反向基向量
  let normal: vec3<f32> = vec3<f32>(0., 0., 1.);
  let dot: f32 = dot(invDir, normal);

  if (abs(dot) < EPS) {
    // 射线平行或背向光源
    return vec3<f32>(MAX_RAY_LENGTH, MAX_RAY_LENGTH, MAX_RAY_LENGTH);
  }

  // 射线原点变换到光源本地空间
  let invOrigin: vec3<f32> = (inverseMat * vec4<f32>(ray.origin, 1.)).xyz;
  // 求取相交段的射线步长
  let t: f32 = dot(-invOrigin, normal) / dot;

  if (t < EPS) {
    return vec3<f32>(MAX_RAY_LENGTH, MAX_RAY_LENGTH, MAX_RAY_LENGTH);
  }

  return vec3<f32>(invOrigin.xy + t * invDir.xy, t);
}

fn hitTestLights(ray: Ray) -> vec4<f32> {
  let areaLight: LightInfo = global.u_lightInfos[0];
  // xyz存光的颜色,w存射线相交段的`t`
  var res: vec4<f32> = vec4<f32>(areaLight.color.rgb, MAX_RAY_LENGTH);
  // 目前只支持一个光源
  if (areaLight.lightType != LIGHT_TYPE_AREA) {
    return res;
  }

  // 求得XZ平面上的交点,以及相交段射线的`t`
  let xyt: vec3<f32> = hitTestXZPlane(ray, areaLight.worldTransformInverse);

  var hit: bool = false;
  if (areaLight.areaMode == LIGHT_AREA_DISC) {
    // 判定是否在圆盘内
    hit = length(xyt.xy) < areaLight.areaSize.x;
  } else {
    // 判定是否在矩形内
    hit = all(abs(xyt.xy) < areaLight.areaSize / 2.);
  }

  if (hit) {
    res.a = xyt.z;
  }

  return res;
}

展示

以上就是本文的所有内容,但在最后,为了调试,我还做了一个功能来将将整个BVH的包围盒画出来,其实原理也很简单:

  1. 通过包围盒信息,生成line类型的图元数据(主要是索引数据是一个图元两个点,而非三角形的三个点)。
  2. 组装Mesh绘制。

由于太过简单这里就不详细讲了,直接看看最后的结果吧:

""

在蚂蚁IPO前夕离职的天宇

作者 dtysky
2021年9月21日 10:00

本文是南方周末《非虚构写作课程》的作业4。


2020年7月21日凌晨,难以入睡的天宇在床上辗转反侧。在这没有灯光的房间中,他不时解锁着身边的手机,这屏幕在黑暗中显得额外刺眼。在这刺眼的屏幕上,显示的是一封邮件,标题是——《蚂蚁金服正式启动IPO流程》。这个消息几乎对于所有人都是意外的,彼时论坛铺天盖地的都是“下周发邮件”的调侃,谁都没想到蚂蚁的IPO来的如此之快。

这次IPO的启动像一颗炸弹,在人群中激起了不小的反响,其中首当其冲的自然是蚂蚁的员工,据调查,蚂蚁员工的持股比例为40%。一时间,杭州黄龙时代和Z空间充满欢呼,成都C空间和上海S空间沸腾雀跃。而尚可算仍在Z空间工作的天宇却没有丝毫开心,因为此时在面前的并非是一个“结果”,而是一个“选择”。

因为早在三周前,他便提交了离职流程。明天,就是这离职的最后一天。

而这个“选择”则在于——离职的流程,是可以撤回的。

回想过去几个月的经历,天宇觉得就像是在坐山车一般。先是一手研发的引擎在新春五福项目大获成功,获得了最高绩效;然后是突如其来的劳心伤财的生活感情问题让他疲惫不堪;存款近乎清零终于解决后,努力撰写的技术分析文章大受好评,Leader的大力支持也预示着未来P8可期;但随之而来的却是团队的巨幅调整,只身和某个团队竞争了一年多的他要求被收编到对方团队。

这一切都发生在短短的三个月之内,而他,来到这里已经两年了。

“我从未想过离职,但这次实在是太过分,碰到了我的底线。”他是这么和同事说的。在支付宝的这两年,他认识了很多靠谱的朋友,事业也突飞猛进,但这次调整无情打碎了这一切。即便如此,他的第一选择也并非是离职,而是转岗,但这转岗的计划转瞬又被另一个调整打碎。

因压力而不满,因公正而不满,因屈辱而不满,也因这两年杭州房价飙升而不满。在无数次纠结后,他终于联系了微信的内推,并通过十轮面试接到了Offer。在接到Offer的当天,他便开始了离职贴的编写,这就是后来内网爆火的《阿里巴巴不再需要年轻人》。

“要说没有泄愤的私心是不可能的,但作为一个标准的INFP,想要反抗现在广泛存在的不公也确实是主要的。”在发了贴后,他向某些关系好的同事解释道。即便是很多人觉得没有必要,只是打工而已,但他对不公的愤恨却从未改变。

当时做出决策的他认为这已经是一个尽善尽美的选择——既没有违背自己的信念,在世俗利益上也没有损伤多少。直到这封邮件的到来。

天宇开始了“计算”,相比于白天还在意气风发的理想主义状态,夜里的此刻他却十分世俗和功利。计算对于他而言是轻松的,从小的贫穷以及即便是富裕了一些后,母亲对钱的执着和斤斤计较,让他对这种计算深恶痛绝,但却又讽刺性地颇具天赋。

为了计算,他起身打开了电脑,建了个表格,这符合一贯的严谨。他一边搜索着各种论坛上对蚂蚁期权未来价值的讨论,一边用计算器不同状况下跳槽后和留下来的待遇,一边计算,一边将这些结果填入表中对比。虽然内心不愿相信,但对比的结果却是客观而精准的——如果选择跳槽,他一年将至少损失五十万,最坏情况下,他将损失一百万。

“没有后路,也没有后盾。追求理想,理想,需要钱。钱,越多越好,越快越好,不知道还能活几年,得快。”在精密的计算、看到结果之后,他的第一个想法并不是如何决策,而是一个现实。而这个现实很简单,虽然一直不愿意面对和倾诉,但却是切实存在的——安全感,无力感,对钱的渴望,尤其是存款所剩无几的现在。

对贫穷的恐惧、对生存的焦虑从未离开过他,也是无法掩盖的。所以他动摇了,并且这个动摇并非没有正当性。事实上从外界看来,就算是是现在撤销离职也不会对他的“信念”有任何影响。就在他告知同事们自己要离职的消息后,收到了很多鼓励,但也收到了不少挽留。有不少的别的部门的同事邀请转岗,甚至有邀请过去当Leader开拓新业务的。

“要说没动心那是不可能的,毕竟对于我而言工作成就感是现在最容易获得的快乐了,但是......”在和同事讨论起挽留的时候,他犹豫了一下,随之沉默。而就在此刻,他打破了这沉默,而是打开了邀请他当TL的那个同事的钉钉窗口,输入了一些文字。但在想点击发送的那一刻,他迟疑了,“理想和现实”、“利益和尊严”的矛盾再次他脑海中产生了,和这矛盾一齐迸发的是无数的词汇——

现实,尊严,理想,利益,大局,自我,压力,捷径,舍弃,保留,无畏,谨慎,渴望,隔绝,肉身,灵魂,手段,神圣,肮脏,分裂,算计,真诚......

之后,他失眠了。

在大约一个月前,也就是即将提出离职的前两周,天宇曾去了广州、也就是他即将入职的微信所在的城市旅游。在这次的行程中唯一让他印象深刻的并不是美食,更不是白云山,而是一个在当代艺术馆举行的展览,其中有一个影像作品是黎朗的《某年某月某日》。影像作者采访了不同地域、不同年龄、不同性别的青年们,他们口述的“梦想、现实、矛盾、选择、坚持”令天宇十分动容,甚至当场泣不成声。

而就是失眠的现在,他回想起了这个展览,由于ADHD和强迫思维,他经常在思考重要文的的时候走神,但这走神之中却总有着关联,这次也不例外。他认为自己和那些青年没有实质的区别,在矛盾存在的状况下,他需要借助过去的经验,想清楚对自己最重要的是什么,才能得出最后的决策。

那么什么是他的梦想呢?在和他人叙述的时候,他说过很多,比如成为作家、做独立游戏、表达理念、关怀边缘群体、为历史的承受者发声。但真正的梦想只有他自己清楚,而且只有一个,那就是——

成为英雄。

他想成为英雄,这并非是孩童的臆想,这是他几乎所有重大决策的信念,这也是他大部分的决策看起来都非理性、却又总有着某种一惯性的原因。英雄要有尊严,所以一切尊严优先;英雄不畏强权,所以宁愿自损一千伤敌八百;英雄要成为焦点,所以总是渴望做出大成绩;英雄要付出代价,所以他总是在不断失去。

这显然是一种执念,他也明白这执念从何而来——来自原生家庭的不幸。他可悲得继承了母亲“我不能让他人小瞧”的悲愿,却在阅读了众多文学作品后,尽力抛弃了这愿望中的功利和世俗,成为了一个标准的理想主义者。他曾经怨恨着这一切,但这毕竟已然成为了一种信念。如果说这执念是加在人生幸福上的一把锁,那么他早已将这把锁的钥匙扔掉了。倘若失去了这种执念,其人生仅剩一片虚无,可能下一瞬间便是自杀,这当然也符合“英雄的代价”。

“那么这一刻,就是最佳的舞台了吧。”他终于想清楚,这是一个绝佳的机会。

26岁,P7,两年绩效全部最高,入职一年光速晋升,支付宝3D大梁之一,技术强,尽职负责。这样的年轻人和腐朽的体制对抗,经历没有半分虚假,本就足够引起共鸣,但如果说是成为英雄,也确实还差了一些什么,这差的就是“代价”。

从这个角度来讲,这封IPO的邮件就不再是引发痛苦、焦虑和悔意的根源,而是“成为英雄”那绝佳的代价,它提供的正是所有英雄的故事中最为动人的——带有悲情的荒诞性。并且这荒诞性加上“两年前从B站离职后B站就上市”事实相加,更是加上了一层绝妙的戏谑。

在这漫长的思考中,窗外迎来了破晓。彻夜未眠的天宇此刻却毫无困意,因为他明白了自己要做的一切,即便是这“英雄”的扮演终是一时的谈资,也终将结束,并且在结束后可能也会有悔意。

但没有关系,因为他过去的人生始终是这样做的,始终是“做出选择,付出代价,即便后悔,绝不回头”。

在简短的洗漱后,他最后一次坐上了通往蚂蚁Z空间的车,这次没有选择拼车。在不久后,那篇离职贴引起了爆发式的讨论,也让他认识了许多的朋友,这也间接改变了他的人生规划。

而在最后,命运再次和他开了个巨大的玩笑。三个月后,新闻里传来了蚂蚁终止IPO的消息,他心中瞬间五味杂陈,却也没有了更多的波动,只能在心里自言自语:

“可能,这就是人生吧,不可预料,充满戏剧性,充满荒诞,充满讽刺。”

【WebGPU实时光追美少女】管线组织与GBuffer

作者 dtysky
2021年9月17日 10:00

本系列文章设计的所有代码均已开源,Github仓库在这里:dtysky/webgpu-renderer

上一篇文章场景数据组织与合并,我们讨论了要使用的材质、以及如何组织好场景数据,并对场景数据进行了合并。现在我们可以认为有了一个很大的Geometry,其中存储着整个场景所有的图元信息,还有合并好的UniformTexture,以便在渲染过程中查找到物体上某一点对应的材质数据。那么下一步,就是如何利用这些数据进行渲染了。

路径追踪管线

路径追踪管线和传统的光栅化管线是完全不同的,目前一般有两种方案来实现。在设计整个管线之前,我们要先回顾并深入一下路径追踪的原理。

追踪过程

""

还是这张图,但这次我们要更深入一些。首先从左侧的相机看起,路径追踪起始于相机发出的若干世界空间的射线,这些射线进入场景后:

  1. 首先进行相交测试,从结果上来看,我们需要的是第一个和射线相交的三角面,换言之是离相机最近的相交三角面
  2. 获取相交点的材质数据,进行着色。
  3. 生成下一条/多条射线。
  4. 对每一条射线进行相交测试、着色。
  5. 循环过程直到满足结束条件,累计结果。

通过对这几步的抽象,我们已经可以给出一个不同于传统光栅化流程的全新管线实现,事实上这就是新一代图形API提供的光线追踪管线(XXX Ray tracing pipeline)的实现,比如M$的DXR。不同于光栅化的VS -> Rasterization -> FS管线,这个管线被抽象为下图的形式:

""

在RTX硬件的支持下,这个管线的性能是非常不错的,也可以和光栅化管线同时存在、相互协作。但显然现在WebGPU目前是不支持的,所以我们只能寻求另一种方案——CS + GBuffer的混合方案。

CS和GBuffer混合方案

说是CS(计算着色器)和GBuffer(几何缓冲对象)的混合方案,实际上只有CS也可以。让我们考虑一下,CS使用通用逻辑单元进行计算,理论上可以完成任何渲染需要的计算,甚至是光栅化也可以,只不过在大部分情况下比较慢(但事实上已经有这样的应用了)。我们现在已经有了整个场景需要用于渲染的的所有数据,那么将它们直接传给CS,理论上当然可以完成路径追踪的计算,只不过比起专用硬件慢了一些而已。

既然如此,我们又为何需要GBuffer呢?这不是画蛇添足?其实很简单——为了性能,虽然CS比起硬件专用加速方案慢,但能快一点总归是好的,而且这在原理上也是行得通的。

让我们再回忆下光栅化的过程,光栅化最重要的分为两步——投影和栅格化,投影是将3D空间的三角面变换到近裁剪平面的三角形,栅格化则是将近裁剪平面的矢量三角形变换为一个个像素(片元);再对比下路径追踪流程——根据相机投影模式,从近裁剪平面的像素发射一条条射线,找到射线相交的最近三角面上的点。

相信读者已经可以觉察到了——从结果上来讲,光栅化,实际上就是路径追踪的第一步,也就是求取某个像素发出的射线和场景中三角面的第一个交点。而光栅化由于硬件的高度优化,速度是非常快的。而除了速度优势,GBuffer在后面要提到的降噪方面也有用处。

所以总结来看,到此我们的渲染管线至少有两步——

  1. 通过光栅化流程渲染GBuffer。
  2. 通过CS进行后续的路径追踪流程。

BVH

在上面的绘制流程我们提到了第一步是求得射线和场景中最近的三角面的交点,但并没有提到怎么做。最简单的方法当然是遍历整个场景所有三角面,但考虑到射线数量以及三角面的数量,这显然是不现实的,所以需要一个结构来进行加速,加速结构的分类会在后面讲到,这里只给结论——我最后使用了BVH,这也是业界一般的做法。

由于在相交测试就会使用到BVH,所以它的构建应当先于整个CS流程。但概论中已经说了,本项目目前只针对静态场景,不涉及到Mesh的增减,所以BVH只需要在资源加载、场景组织完成后做一次即可。

降噪和色调映射

最后还需要的流程就是降噪和色调映射了。由于我们要做的是实时路径追踪,并且由于原理(涉及到积分),每次弹射出复数射线进行积分显然是不现实的,所以只能时间换空间——利用随机采样的原理,每帧渲染出一个带有大量噪点的结果,最后进行时域滤波,再辅以空间滤波,得到相对可以接受的最终结果。这整个滤波过程就是降噪

同时路径追踪渲染出的结果最终基本都是高动态范围(HDR)的,为了让显示器能够正确显示,我们最后还要进行一个通用操作色调映射(Tone mapping),将图像亮度压缩到LDR范围,并尽可能保证细节。

下图表现出了降噪前和降噪+色调映射后的结果对比:

""

总结

至此,可以总结出整个路径追踪管线的组成:

  1. 构建BVH。
  2. 通过光栅化流程渲染GBuffer。
  3. 通过CS进行后续的路径追踪流程。
  4. 降噪。
  5. 色调映射。

GBuffer渲染

讲完了管线的组成,就让我们以GBuffer为开端,顺便实战一下之前文章论述的这个WebGPU渲染器吧。

构建Mesh

GBuffer的渲染走的是正常的光栅化流程,所以需要构建一个Mesh,这个Mesh需要的Geometry和Material可以直接使用上一篇文章合并过的场景数据:

protected _buildGBufferMesh = () => {
  const { _attributesInfo, _indexInfo, _commonUniforms } = this;

  const geometry = new Geometry(
    Object.keys(_attributesInfo).map((name, index) => {
      const { value, length, format } = (_attributesInfo[name] as any) as IBVHAttributeValue;

      return {
        layout: {
          arrayStride: length * 4,
          attributes: [{
            name, offset: 0, format, shaderLocation: index
          }]
        },
        data: value,
        usage: GPUBufferUsage.STORAGE
      }
    }),
    _indexInfo.value,
    _indexInfo.value.length
  );

  const material = new Material(buildinEffects.rRTGBuffer, {
    u_matId2TexturesId: _commonUniforms.matId2TexturesId,
    u_baseColorFactors: _commonUniforms.baseColorFactors,
    u_metallicRoughnessFactorNormalScaleMaterialTypes: _commonUniforms.metallicRoughnessFactorNormalScaleMaterialTypes,
    u_specularGlossinessFactors: _commonUniforms.specularGlossinessFactors,
    u_baseColorTextures: _commonUniforms.baseColorTextures,
    u_normalTextures: _commonUniforms.normalTextures,
    u_metalRoughOrSpecGlossTextures: _commonUniforms.metalRoughOrSpecGlossTextures
  });

  this._gBufferMesh = new Mesh(geometry, material);
}

可见这里构建的Geometry没有使用交错形式,而是使用了多个Buffer,每个Buffer对应一个Attribute。而Material的UniformBlock直接使用了场景合并过的各个Uniform和Texture,同时使用了buildinEffects.rRTGBuffer这个Effect。

Effect

buildinEffects是引擎内置的一些Effect,rRTGBuffer如其名是用于渲染GBuffer的,前缀的r表明是用于Render的。对于一个Effect,最重要的就是它的Shader。

首先是顶点着色器:

struct VertexOutput {
  [[builtin(position)]] position: vec4<f32>;
  [[location(0)]] wPosition: vec4<f32>;
  [[location(1)]] texcoord_0: vec2<f32>;
  [[location(2)]] normal: vec3<f32>;
  [[location(3)]] meshMatIndex: vec2<u32>;
};

[[stage(vertex)]]
fn main(attrs: Attrs) -> VertexOutput {
  var output: VertexOutput;

  let wPosition: vec4<f32> = vec4<f32>(attrs.position, 1.);

  output.position = global.u_vp * wPosition;
  output.wPosition = wPosition;
  output.texcoord_0 = attrs.texcoord_0;
  output.normal = attrs.normal;
  output.meshMatIndex.x = attrs.meshMatIndex.x;
  output.meshMatIndex.y = attrs.meshMatIndex.y;

  return output;
}

可见比较简单,由于直接使用了合并过的顶点数据,所以不需要Model -> World变换,输出给光栅器重点处理的position只需要乘以VP矩阵即可,剩下要用于插值的数据有世界坐标、UV和法线这三个f32向量,以及meshMatIndex这个存储顶点材质索引的u32向量。

之后是片段着色器:

struct VertexOutput {
  [[builtin(position)]] position: vec4<f32>;
  [[location(0)]] wPosition: vec4<f32>;
  [[location(1)]] texcoord_0: vec2<f32>;
  [[location(2)]] normal: vec3<f32>;
  [[location(3)]] meshMatIndex: vec2<u32>;
};

struct FragmentOutput {
  [[location(0)]] positionMetalOrSpec: vec4<f32>;
  [[location(1)]] baseColorRoughOrGloss: vec4<f32>;
  [[location(2)]] normalGlass: vec4<f32>;
  [[location(3)]] meshIndexMatIndexMatType: vec4<u32>;
};

fn getRoughness(factor: f32, textureId: i32, uv: vec2<f32>) -> f32 {
  if (textureId == -1) {
    return factor;
  }

  return factor * textureSampleLevel(u_metalRoughOrSpecGlossTextures, u_sampler, uv, textureId, 0.).g;
}

fn getMetallic(factor: f32, textureId: i32, uv: vec2<f32>) -> f32 {
  if (textureId == -1) {
    return factor;
  }

  return factor * textureSampleLevel(u_metalRoughOrSpecGlossTextures, u_sampler, uv, textureId, 0.).b;
}

fn getSpecular(factor: vec3<f32>, textureId: i32, uv: vec2<f32>) -> vec3<f32> {
  if (textureId == -1) {
    return factor;
  }

  return factor * textureSampleLevel(u_metalRoughOrSpecGlossTextures, u_sampler, uv, textureId, 0.).rgb;
}

fn getGlossiness(factor: f32, textureId: i32, uv: vec2<f32>) -> f32 {
  if (textureId == -1) {
    return factor;
  }

  return factor * textureSampleLevel(u_metalRoughOrSpecGlossTextures, u_sampler, uv, textureId, 0.).a;
}

fn getBaseColor(factor: vec4<f32>, textureId: i32, uv: vec2<f32>) -> vec4<f32> {
  if (textureId == -1) {
    return factor;
  }

  return factor * textureSampleLevel(u_baseColorTextures, u_sampler, uv, textureId, 0.);
}

fn getFaceNormal(position: vec3<f32>) -> vec3<f32> {
  return normalize(cross(dpdy(position), dpdx(position)));
}

fn getNormal(
  vNormal: vec3<f32>, position: vec3<f32>, faceNormal: vec3<f32>,
  textureId: i32, uv: vec2<f32>, normalScale: f32
) -> vec3<f32> {
  var normal: vec3<f32> = normalize(vNormal);
  normal = normal * sign(dot(normal, faceNormal));

  if (textureId == -1) {
    return normal;
  }

  // http://www.thetenthplanet.de/archives/1180
  let dp1: vec3<f32> = dpdx(position);
  let dp2: vec3<f32> = dpdy(position);
  let duv1: vec2<f32> = dpdx(uv);
  let duv2: vec2<f32> = dpdy(uv);
  let dp2perp: vec3<f32> = cross(dp2, normal);
  let dp1perp: vec3<f32> = cross(normal, dp1);
  var dpdu: vec3<f32> = dp2perp * duv1.x + dp1perp * duv2.x;
  var dpdv: vec3<f32> = dp2perp * duv1.y + dp1perp * duv2.y;
  let invmax: f32 = inverseSqrt(max(dot(dpdu, dpdu), dot(dpdv, dpdv)));
  dpdu = dpdu * invmax;
  dpdv = dpdv * invmax;
  let tbn: mat3x3<f32> = mat3x3<f32>(dpdu, dpdv, normal);
  var tNormal: vec3<f32> = 2. * textureSample(u_normalTextures, u_sampler, uv, textureId).xyz - 1.;
  tNormal = tNormal * vec3<f32>(normalScale, normalScale, 1.);

  return normalize(tbn * tNormal);
}


[[stage(fragment)]]
fn main(vo: VertexOutput) -> FragmentOutput {
  var fo: FragmentOutput;

  let meshId: u32 = vo.meshMatIndex[0];
  let matId: u32 = vo.meshMatIndex[1];
  let metallicRoughnessFactorNormalScaleMaterialType: vec4<f32> = material.u_metallicRoughnessFactorNormalScaleMaterialTypes[matId];
  let specularGlossinessFactor: vec4<f32> = material.u_specularGlossinessFactors[matId];
  let textureIds: vec4<i32> = material.u_matId2TexturesId[matId];
  let matType = u32(metallicRoughnessFactorNormalScaleMaterialType[3]);
  let isSpecGloss: bool = isMatSpecGloss(matType);

  fo.positionMetalOrSpec = vec4<f32>(vo.wPosition.xyz, 0.);

  let baseColor: vec4<f32> = getBaseColor(material.u_baseColorFactors[matId], textureIds[0], vo.texcoord_0);
  fo.baseColorRoughOrGloss = vec4<f32>(baseColor.rgb, 0.);

  if (isSpecGloss) {
    fo.positionMetalOrSpec.w = getSpecular(specularGlossinessFactor.xyz, textureIds[2], vo.texcoord_0).r;
    fo.baseColorRoughOrGloss.w = getGlossiness(specularGlossinessFactor[3], textureIds[2], vo.texcoord_0);
  } else {
    fo.positionMetalOrSpec.w = getMetallic(metallicRoughnessFactorNormalScaleMaterialType[0], textureIds[2], vo.texcoord_0);
    fo.baseColorRoughOrGloss.w = getRoughness(metallicRoughnessFactorNormalScaleMaterialType[1], textureIds[2], vo.texcoord_0);
  }

  let faceNormal: vec3<f32> = getFaceNormal(vo.wPosition.xyz);
  fo.normalGlass = vec4<f32>(
    getNormal(vo.normal, vo.wPosition.xyz, faceNormal, textureIds[1], vo.texcoord_0, metallicRoughnessFactorNormalScaleMaterialType[2]),
    baseColor.a
  );

  fo.meshIndexMatIndexMatType = vec4<u32>(meshId, matId, matType, 2u);

  return fo;
}

可见这个比较顶点处理就复杂多了,但其实也不难理解。从宏观角度来看,片元处理的目的就是将需要的数据写入到输出的目标纹理中,这里我们可以回忆一下在只文章WebGPU基础与简单渲染器中讲过的RenderTexture,那时候提到了它支持多渲染目标MRT,其实就是主要用在了GBuffer中。要使用MRT,我们首先需要在CPU创建一个RenderTexture(下一节会讲),然后在FS中按照顺序定义好FragmentOutput作为它的输出,即可完成CPU和GPU两端的映射。

在实际的Shader编程中,我们可以认为FS可以输出到不同的纹理,本FS就输出到了以下几个纹理中:

  1. positionMetalOrSpec:存储世界空间的坐标,以及金属工作流下的metallic或者高光工作流下的specular,注意specular本来有三个通道,但这里只保留一个,这是出于节省空间,而且实际场合中其实一个通道也不是不行(逃。
  2. baseColorRoughOrGloss:存储baseColor,以及金属工作流下的roughness或者高光工作流下的glossiness
  3. normalGlass:存储世界空间的顶点法线normal,以及透射材质的折射率的倒数glass,之所以存倒数,是因为实际应用中折射率基本都大于1,而调整的时候0~1比较好调。
  4. meshIndexMatIndexMatType:存储顶点索引、材质索引、材质类型,注意这是一个u8向量,意味着整个场景支持的Mesh和材质数量最多256。

而其中的大部分计算其实都是索引,比如通过vo.meshMatIndex[1]拿到这个片元对应的材质id,然后通过material.u_matId2TexturesId[matId]获取到几种纹理的id数组,再通过textureIds[0]获取到对应baseColor纹理的id,最后用这个id和uv去用textureSampleLevel(u_baseColorTextures, u_sampler, uv, textureId, 0.)采样数组纹理,获得最终的baseColor值。

在这些最终输出到纹理的数据的计算中,有几个需要特别说明:

法线计算

和其他数据不同,这里法线的计算比较复杂,因为法线本身就有其复杂性,每个着色的片元都有自己的顶点法线、面法线和可能的法线贴图采样值:

  1. 顶点法线:顶点数据中的法线插值而来,如果没有法线贴图,就是最终的法线矢量的
  2. 面法线:顶点所在三角面的法线,一般用于和顶点法线计算求取法线矢量最终的方向
  3. 法线贴图:由于物体的表面会有复杂的凹凸不平,全部用几何信息描述开销过高,而使用贴图来存储法线信息能有效降低开销,一般会构建一个切线空间,将实际的顶点法线换算到其中,在渲染时还原。为何要使用切线空间读者可以自行查找,不再赘述。

面法线的一般计算方法是利用三角面两个边向量的叉乘,在片元着色器中无法直接获取边向量的信息,但有Derivative functions,可以用于近似计算,具体实现为上面的getFaceNormal函数,这里我们计算了世界空间坐标对屏幕空间坐标的导数,最终近似求得世界空间的面法线

而对于最终法线的求取,如果没有使用法线贴图,那么直接用已有的世界空间顶点法线和面法线点乘求得方向,返回即可。但如果有法线贴图,就需要涉及到切线空间的反变换了,如果使用传统的算法,还需要提供预计算的顶点切线数据Tangent,最终利用顶点法线、切线构造TBN矩阵,但由于懒得生成,这里就使用了这篇文章介绍的方法,其本质上是还原了切线的预计算过程。当然这也是近似,毕竟无法拿到真正的三角面的三个顶点数据。

meshIndexMatIndexMatType的alpha通道

读者可能刚才已经疑惑了为什么这个纹理的alpha通道要给个2,这其实是处于后续运算的考虑。在实际的渲染中,并非每个像素都会被三角面覆盖,所以一些像素是没有值的,对应于路径追踪流程,就是这个像素射出的射线和场景物体没有任何交点。至于为何是2而不是1,因为alpha会被相机clear为1,设计而已。

渲染和展示

现在我们有了GBuffer需要的Mesh,接下来只需要创建用于MRT的RenderTexture,并将它们关联起来:

this._gBufferRT = new H.RenderTexture({
  width: renderEnv.width,
  height: renderEnv.height,
  colors: [
    {name: 'positionMetalOrSpec', format: 'rgba16float'},
    {name: 'baseColorRoughOrGloss', format: 'rgba16float'},
    {name: 'normalGlass', format: 'rgba16float'},
    {name: 'meshIndexMatIndexMatType', format: 'rgba8uint'}
  ],
  depthStencil: {needStencil: false}
});

const {material} = this._gBufferDebugMesh = new H.ImageMesh(new H.Material(H.buildinEffects.iRTGShow));
material.setUniform('u_gbPositionMetalOrSpec', this._gBufferRT, 'positionMetalOrSpec');
material.setUniform('u_gbBaseColorRoughOrGloss', this._gBufferRT, 'baseColorRoughOrGloss');
material.setUniform('u_gbNormalGlass', this._gBufferRT, 'normalGlass');
material.setUniform('u_gbMeshIndexMatIndexMatType', this._gBufferRT, 'meshIndexMatIndexMatType');

然后渲染就OK:

this._scene.setRenderTarget(this._gBufferRT);
this._scene.renderCamera(this._camera, [this._rtManager.gBufferMesh]);

最后我用一个ImageMesh将最终的结果显示了出来,如图:

""

【WebGPU实时光追美少女】场景数据组织与合并

作者 dtysky
2021年9月15日 08:00

本系列文章设计的所有代码均已开源,Github仓库在这里:dtysky/webgpu-renderer

本文将在上一篇文章WebGPU基础与简单渲染器的基础上,论述针对路径追踪,应当如何组织场景数据,这涉及到PBR材质定义、glTF资源的导出和加载、场景的构建、以及最重要的场景合并等内容。

PBR材质

这篇文章以PBR材质开始,为什么要以材质开始?因为如上一篇文章所言,材质(以及效果)决定了物体的渲染方式,也可以从某种程度上认为是决定了使用光照模型(或者说是光照模型决定了材质和效果),这个对整个场景数据和渲染流程的组织有决定性的影响。为了搞清楚为何要如此组织数据和渲染流程,我们必须要先了解使用到的材质。

PBR即“基于物理的渲染”是工业界的说法,其可以认为是基于相对物理正确的BRDF、BSDF等模型,根据不同的场合,抽象了一些参数供美术人员调节,并加上一些技术(比如用于实时渲染的预计算IBL、SH技术等等)来达到相对物理真实的效果。不过这里我们不讨论背后实现的理论细节,这些细节将在后续的章节详细论述,本文只需要了解这些参数和工作流即可。

事实上,在光栅化渲染流程如果单纯使用PBR材质进行直接光照,而不使用IBL/SH进行间接光照,相对于传统的光照模型,并不能带来多大的提升,所以Baking是十分重要的。

根源上来讲,PBR材质只是一个统称,其有很多变种,这些变种一般被称为工作流(Workflow)。同时这些变种也有不同的定义和实现,比如著名的迪士尼的Disney Principled BRDF。而本引擎的定义则基于后续要提到的glTF资源中的标准定义,支持了两种最常见的工作流——金属(MetallicRoughness)和高光(SpecularGlossiness)工作流。

Metallic Roughness

""

金属工作流,是指主要通过金属度和粗糙度来控制渲染效果,这实际上模拟的是自然界的导体,也有一些其他参数来共同决定最终效果:

  1. Metallic:金属度,由单通道贴图metallicMap和系数metallicFactor相乘得到。
  2. Roughness:粗糙度,由单通道贴图roughnessMap和系数roughnessFactor相乘得到。
  3. BaseColor:反照率,有的引擎也称为Albedo,由RGBA贴图baseColorMap和颜色系数baseColorFactor相乘得到。
  4. Occlusion:环境光遮蔽,由单通道贴图occlusionMap和系数occlusionFactor相乘得到。
  5. Normal:法线贴图,一般是切线空间的法线贴图normalMap和系数normalScale计算得到。
  6. Emission:自发光,由RGBA贴图emissiveMap和颜色系数emissiveFactor相乘得到。

在实际使用中,metallicMaproughnessMapocclusionMap一般会合并成一张RGB贴图。

Specular Glossiness

""

除了金属度和粗糙度,高光工作流的大部分参数和金属工作流都是通用的,但高光和光泽度是独有的,也是控制渲染的最主要因素:

  1. Specular:高光,由RGB贴图specularMap和颜色系数specularFactor相乘得到。
  2. Glossiness:光泽度,由单通道贴图glossinessMap和系数glossinessFactor相乘得到。

这里要注意除了BaseColor和Emissive一般是SRGB空间的,其他都是线性空间的,并且在实际计算时都是转到线性空间计算的,这也是物理正确的要求。

glTF资源

有了材质的定义,接下来的问题就是如何将它们应用到物体上了,这又引出了更多的问题——我们该如何组织整个场景?又如何去存储它们、如何去生成它们?答案很简单——使用glTF格式。

glTF是KhronosGroup近年来提出的一种标准模型格式,从某种意义上你可以认为它是3D场景序列化的一种标准解决方案。其方案非常简单明确,基本由一个存储节点、曲面、材质、纹理、动画等等索引信息的json格式的.gltf文件,同时还提供了一个二进制.bin文件用于存储实际的顶点、动画信息等,而这个二进制文件中的数据和GL所需格式基本一致,这大幅降低加载延迟、提升了首屏速度。更多详细的信息可以看这里——Sein.js glTF和实例化

读者可能会疑惑为何这里会提及另一个引擎,因为这个引擎是我还在支付宝供职时研发的(当然已开源),同时还为其开发了配套的Unity扩展——SeinjsUnityToolkit,而本引擎也可以直接使用这个工具导出的资源,也算是物尽其用吧,但相比于工具支持的所有特性,本引擎只用到了需要的部分:

  1. 贴图。
  2. 2D和Cube纹理。
  3. PBR材质,主要是glTF官方内置的金属工作流,以及通过KHR_materials_pbrSpecularGlossiness扩展实现的高光工作流。
  4. 图元和Mesh,直接对应引擎中的GeometryMesh
  5. 相机,支持正交和透视。
  6. 灯光,在路径追踪中主要用到了rectdisc两种面光源。
  7. 结点,用于构建节点树。

整个加载器的代码的实现在resource/GlTFLoader内,实际使用中,只需要调用load方法,之后将返回的结果中的根节点rootNode添加给场景的根节点即可:

const model = this._model = await H.resource.load({type: 'gltf', name: 'scene.gltf', src: MODEL_SRC});
if (model.cameras.length) {
  this._camera = model.cameras[0];
}
_scene.rootNode.addChild(model.rootNode);

场景合并

加载完资源并将其添加到场景后,我们有了一整颗节点树,节点树上也有了不同的MeshLightCamera,而Mesh则拥有GeometryMaterialMaterial全部是PBR材质,并使用UniformBlock将需要的参数管理了起来。如果是传统的光栅化流程,我们可以直接开始渲染——Camera剔除出需要的Mesh,将lightInfo、VP矩阵等写入全局UB,之后分别绘制每个Mesh.....但对于路径追踪流程,却不能这么做,原因很简单——信息不足。

""

上面这张图生动地描述了两个流程的差异(当然第二个实际上是光线追踪流程,不过用于描述差距还是足够了,把射线反过来就OK)。

光栅化流程实际上是将场景中的每个三角面投影到近裁剪面上,然后通过算法将矢量的三角形栅格化为像素(片元),之后经过重心坐标插值、像素着色、各种测试、混合等等决定屏幕空间某些像素最后的颜色值,这实际上是反直觉的,并且只能计算直接光照。

而路径追踪则不同,其出发点并非三角形,而是屏幕空间的像素,我们通过相机坐标->像素对应世界空间坐标生成一条世界空间的射线,然后让其和场景中所有三角面求交,找到最近的相交点后再通过反射/折射计算光路,最终可以通过N次弹射,最终收集完整个场景对此像素的贡献。除了部分场景(比如焦散),单向的路径追踪可以比较准确地计算出全局光照的结果。

那么这些和场景合并又有什么关系呢?当然有——路径追踪需要在每个射线求交的过程中拥有整个场景所有三角面的信息,除此之外,由于交点可能存在于任意三角面上,所以也需要能够即时获取到三角面对应的材质信息

有经验的读者可能已经联想到了,这个特征其实和工业界讨论许久的GPU Driven有些相似。

GPU Driven Rendering Pipeline

GPU Driven Rendering指的是一类渲染技术,其根本目的是为了充分利用GPU的计算能力,使用Bindless Resource、Virtual Texture、Indirect Draw等等技术,尽可能减少渲染过程中CPU和GPU之间数据传输、调用的cost,还可以充分控制利用cache,来提升性能。这里最主要的就是上一篇文章前面提到过剔除、资源绑定、绘制等等。此技术的细节比较复杂,需要很多工程上的工作,有兴趣的读者可以自行搜索。对于本文我们只需要关心它和路径追踪相关的内容,也就是——资源绑定部分。

资源绑定在WebGPU中,实质上就是上篇文章提到pass.setIndexBufferpass.setVertexBufferpass.setBindingGroup等等方法,CPU通过这些方法告诉GPU我们接下来的绘制指令将会使用哪些顶点数据、Uniform数据......之后进行渲染。但因为硬件结构和软件设计,资源绑定操作是有开销的,一部分是指令本身、另一部分就是GPU中的计算单元获取这些资源对应Buffer的机制,那么如何尽量减少这些操作便是一个可以考虑的问题。

这里我们可以回忆一下Instanced Drawing,即GPU实例化的机制,这个机制它提供一个一次性绘制一批相同图元、相同渲染状态,但可以有不同位置、颜色等等的Mesh的方法。无论是使用InstanceBuffer,还是使用instanceId + const buffer/texture array的机制,本质上都是将这些Mesh中不同的Uniform(比如worldMatrix、color)转换成可以按照实例索引的数据,然后按照实例的维度去索引。这实际上是以在CPU对这些数据打包为代价,降低了实际渲染时绑定和绘制指令的大幅降低(当然,绑定这个操作很久之前就已经可以通过VAO来降低,在新的技术里也有Bindless Resource来降低)。

那么在这个基础上进一步考虑,我们是否可以将图元、某些渲染状态、大量Uniform都不一致的Mesh一批渲染完成呢?当然可以——这就是合批(Batch)技术。在目前常见的光栅化渲染管线(比如ForwardBase)中,合批一般指将相同渲染状态、只有少数Uniform不同的Mesh进行合并的技术,一般在简单的图元、UI上比较常用,其原理是Mesh的某些顶点数据和对应的uniform预先在CPU处理好(比如position和worldMatrix相乘得到worldPos),并将这些Mesh图元数据合并,被合批的uniform统一,便可以实现一次DrawCall绘制大量Mesh。

GPU实例化和合批在目前的游戏中都有大量应用,而GPU Driven的资源部分可以认为是基于它们和一些新技术,做得更加彻底、做了更多优化、也更为复杂。本项目用于路径追踪的部分实际上也可以认为结合了这两者,但毕竟和光栅化流程面对的问题不完全相同,所以做法也不完全一致。

顶点合并

整个路径追踪场景数据管理相关的代码都在extension/RayTracingManager中。

首先要做的就是顶点数据vertexData的合并,这个流程和传统的合批流程基本一致,将位置position和法线normal转换到世界空间,其他的直接拼接即可。如果只是为了合并,其实也可以不转换,然后在下面的流程将worldMatrix合并,通过索引后续计算也行,但由于构建BVH必须要使用世界空间的坐标,这里也就必须要合并,至于法线也只是顺带算了。在合并顶点数据的过程中,还需要同时构建索引数据indexData

protected _buildAttributeBuffers = (meshes: Mesh[]) => {
  const {_materials} = this;

  let indexCount: number = 0;
  let vertexCount: number = 0;
  meshes.forEach(mesh => {
    vertexCount += mesh.geometry.vertexCount;
    indexCount += mesh.geometry.count;
  });

  const {value: indexes} = this._indexInfo = {
    value: new Uint32Array(indexCount)
  };

  const {position, texcoord_0, normal, meshMatIndex} = this._attributesInfo = {
    position: {
      // alignment of vec3 is 16bytes!
      value: new Float32Array(vertexCount * 4),
      length: 4,
      format: 'float32x3'
    },
    texcoord_0: {
      value: new Float32Array(vertexCount * 2),
      length: 2,
      format: 'float32x2'
    },
    normal: {
      // alignment of vec3 is 16bytes!
      value: new Float32Array(vertexCount * 4),
      length: 4,
      format: 'float32x3'
    },
    meshMatIndex: {
      value: new Uint32Array(vertexCount * 2),
      length: 2,
      format: 'uint32x2'
    }
  };

  let attrOffset: number = 0;
  let indexOffset: number = 0;

  for (let meshIndex = 0; meshIndex < meshes.length; meshIndex += 1) {
    const mesh = meshes[meshIndex];
    const {worldMat} = mesh;
    const quat = mat4.getRotation(new Float32Array(4), worldMat) as Float32Array;
    const {geometry, material} = mesh;
    const {indexData, vertexInfo, vertexCount, count} = geometry;

    if (material.effect.name !== 'rPBR') {
      throw new Error('Only support Effect rPBR!');
    }

    let materialIndex = _materials.indexOf(material);
    if (materialIndex < 0) {
      _materials.push(material);
      materialIndex = _materials.length - 1;
    }

    indexData.forEach((value: number, index: number) => {
      indexes[index + indexOffset] = value + attrOffset;
    });

    if (!vertexInfo.normal) {
      geometry.calculateNormals();
    }

    for (let index = 0; index < vertexCount; index += 1) {
      this._copyAttribute(vertexInfo.position, position, attrOffset, index, worldMat);
      this._copyAttribute(vertexInfo.texcoord_0, texcoord_0, attrOffset, index);
      this._copyAttribute(vertexInfo.normal, normal, attrOffset, index, quat, true);

      meshMatIndex.value.set([meshIndex, materialIndex], (attrOffset + index) * meshMatIndex.length);
    }

    indexOffset += count;
    attrOffset += vertexCount;
  }
}

有读者应该注意到了打包的除了传统的那些顶点数据,还有meshMatIndex,这其实是记录了当前顶点属于哪个Mesh(meshIndex),拥有哪个材质(matIndex)。这是因为光有点点数据并不足以支撑整个渲染流程,所以需要通过某个顶点获取到对应的材质。于是我借鉴上面说到的GPU实例化思想——通过材质ID来索引,这也就引出了“材质合并”。

材质合并

所谓材质合并,其实是将材质中的UniformBlock进行合并(Mesh级别的UB目前只有worldMatrix,已经在上一步合过了),来准备给每个顶点的matIndex索引出渲染需要的参数。前面提到过我们使用的都是PBR材质,所以合并的也就是这些参数:

protected _buildCommonUniforms = (materials: Material[]) => {
  const matId2TexturesId = new Int32Array(materials.length * 4).fill(-1);
  const baseColorFactors = new Float32Array(materials.length * 4).fill(1);
  const metallicRoughnessFactorNormalScaleMaterialTypes = new Float32Array(materials.length * 4).fill(1);
  const specularGlossinessFactors = new Float32Array(materials.length * 4).fill(1);
  const baseColorTextures: Texture[] = [];
  const normalTextures: Texture[] = [];
  const metalRoughOrSpecGlossTextures: Texture[] = [];

  materials.forEach((mat, index) => {
    const useGlass = mat.marcos['USE_GLASS'];
    const useSpecGloss = mat.marcos['USE_SPEC_GLOSS'];
    const baseColorFactor = mat.getUniform('u_baseColorFactor') as Float32Array;
    const metallicFactor = mat.getUniform('u_metallicFactor') as Float32Array;
    const roughnessFactor = mat.getUniform('u_roughnessFactor') as Float32Array;
    const specularFactor = mat.getUniform('u_specularFactor') as Float32Array;
    const glossinessFactor = mat.getUniform('u_glossinessFactor') as Float32Array;
    const normalScale = mat.getUniform('u_normalTextureScale') as Float32Array;
    const baseColorTexture = mat.getUniform('u_baseColorTexture') as Texture;
    const normalTexture = mat.getUniform('u_normalTexture') as Texture;
    const metallicRoughnessTexture = mat.getUniform('u_metallicRoughnessTexture') as Texture;
    const specularGlossinessTexture = mat.getUniform('u_specularGlossinessTexture') as Texture;

    const mid = index * 4;
    baseColorTexture !== buildinTextures.empty && this._setTextures(mid, baseColorTextures, baseColorTexture, matId2TexturesId);
    normalTexture !== buildinTextures.empty && this._setTextures(mid + 1, normalTextures, normalTexture, matId2TexturesId);
    baseColorFactor && baseColorFactors.set(baseColorFactor, index * 4);
    normalScale !== undefined && metallicRoughnessFactorNormalScaleMaterialTypes.set(normalScale.slice(0, 1), index * 4 + 2);

    if (useSpecGloss) {
      specularFactor !== undefined && specularGlossinessFactors.set(specularFactor.slice(0, 3), index * 4);
      glossinessFactor !== undefined && specularGlossinessFactors.set(glossinessFactor.slice(0, 1), index * 4 + 3);
      specularGlossinessTexture !== buildinTextures.empty && this._setTextures(mid + 2, metalRoughOrSpecGlossTextures, specularGlossinessTexture, matId2TexturesId);
      metallicRoughnessFactorNormalScaleMaterialTypes.set([useGlass ? EPBRMaterialType.GLASS_SPEC_GLOSS : EPBRMaterialType.SPEC_GLOSS], index * 4 + 3);
    } else {
      metallicFactor !== undefined && metallicRoughnessFactorNormalScaleMaterialTypes.set(metallicFactor.slice(0, 1), index * 4);
      roughnessFactor !== undefined && metallicRoughnessFactorNormalScaleMaterialTypes.set(roughnessFactor.slice(0, 1), index * 4 + 1);
      metallicRoughnessTexture !== buildinTextures.empty && this._setTextures(mid + 2, metalRoughOrSpecGlossTextures, metallicRoughnessTexture, matId2TexturesId);
      metallicRoughnessFactorNormalScaleMaterialTypes.set([useGlass ? EPBRMaterialType.GLASS_METAL_ROUGH : EPBRMaterialType.METAL_ROUGH], index * 4 + 3);
    }
  });

  this._commonUniforms = {
    matId2TexturesId,
    baseColorFactors,
    metallicRoughnessFactorNormalScaleMaterialTypes,
    specularGlossinessFactors,
    baseColorTextures: this._generateTextureArray(baseColorTextures),
    normalTextures: this._generateTextureArray(normalTextures),
    metalRoughOrSpecGlossTextures: this._generateTextureArray(metalRoughOrSpecGlossTextures)
  };
}

可以看到合并的逻辑也比较简单,唯一注意的是合并的规则——都是尽量凑齐16字节对齐的。同时除了材质本身固有的基本参数,还有materialTypematId2TexturesIdmaterialType用于表示材质的类型,分为不透明金属、透明金属、不透明高光、透明高光,不同材质对应的光照计算流程不同。

matId2TexturesId记录的是该材质到纹理id的索引。通过顶点找到材质需要索引,那么通过材质找到需要用到的纹理也需要索引,这是由于不同材质可能复用同一张纹理资源,所以需要同样对纹理进行合并。

纹理合并

纹理合并即将不同的纹理合并成一个可以索引的集合,其并没有看起来那么简单。目前工业界比较成熟和推荐的方案是使用Bindless Texture,但WebGPU并不支持,另外妥协的方案有两个:

  1. 大纹理:创建一张很大的纹理,通过UV映射将需要的纹理分配在大纹理的一个个子区域,存下offset和size来进行采样。
  2. ArrayTexture:创建数组纹理,直接在shader中通过索引采样。

二者各有各的缺点,大纹理的缺点主要是容量有限,对于很多最大支持2048 x 2048的平台很容易超,并且即便是支持不同大小的纹理,也容易产生空间的浪费,当然最主要的还是容量问题。ArrayTexture主要就是要求纹理尺寸必须一致了,但胜在使用方便不太需要担心容量超过。本项目使用的是ArrayTexture:

protected _generateTextureArray(textures: Texture[]): Texture {
  let width: number = 0;
  let height: number = 0;

  textures.forEach(tex => {
    width = Math.max(width, tex.width);
    height = Math.max(height, tex.height);
  });

  const images = textures.map(tex => {
    if (tex.width === width && tex.height === height) {
      return tex.source as TTextureSource;
    }

    if (!(tex.source instanceof ImageBitmap)) {
      throw new Error('Can only resize image bitmap!');
    }

    if (!RayTracingManager.RESIZE_CANVAS) {
      RayTracingManager.RESIZE_CANVAS = document.createElement('canvas');
      RayTracingManager.RESIZE_CANVAS.width = 2048;
      RayTracingManager.RESIZE_CANVAS.height = 2048;
      RayTracingManager.RESIZE_CTX = RayTracingManager.RESIZE_CANVAS.getContext('2d');
    }

    const ctx = RayTracingManager.RESIZE_CTX;
    ctx.drawImage(tex.source as ImageBitmap, 0, 0, width, height);

    return ctx.getImageData(0, 0, width, height).data.buffer;
  })

  return new Texture(
    width, height,
    images,
    textures[0].format
  );
}

至此,整个场景合并部分完成。

【WebGPU实时光追美少女】WebGPU基础与简单渲染器

作者 dtysky
2021年9月13日 09:00

本系列文章设计的所有代码均已开源,Github仓库在这里:dtysky/webgpu-renderer

WebGPU作为Web平台的下一代图形API标准(当然不仅仅是图形),提供了类似于DX12、Metal、Vulkan对GPU深入控制的能力,在经过了数个版本的迭代后,其在当下时点(2021年9月)终于基本稳定,可见WebGPU标准,其使用的着色器语言wgsl也基本稳定,详见WebGPU Shading Language

WTF,就在我写这篇文章这一周(2021年9月第二周)标准又有更新,写完发现原来的代码跑不起来了......待我修一修。

在本文中,笔者将论述如何用WebGPU来实现一个简单的渲染器,并且目前只支持静态物体渲染,没有动画物理等等组件,这个渲染器的架构脱胎于笔者过去研究和实现过的几个渲染引擎,以求尽量简单,而非大而全,所以实现中可能有粗糙之处,但作为如何使用WebGPU的例子而言是绝对足够的。

概述

在设计一个渲染引擎,或者说渲染器的时候,我们最关心的问题毫无疑问是“渲染”,无论如何设计,首要考虑的都是“渲染是如何被驱动的”。一般来讲,目前通用的渲染驱动方案大致都是:

一帧开始 -> 组织提取渲染数据 -> 提交数据更改到GPU -> 设置渲染目标并清屏 -> 提交绘制指令 -> 一帧结束

对应于比较详细的工程概念,帧的调度可以视作是Web中的requestAnimationFrame,以每秒30、60或者120帧驱动;组织提取渲染数据可以分为两部分,一部分是渲染所需资源的管理,另一部分是“剔除”,即通过某种手段在整个场景中筛选出真正需要渲染的物体;而提交必要数据到GPU,则是指渲染过程中可能有一些数据会被更新,比如模型数据、Uniform等等,这时候就需要把这些数据更新上传到GPU;然后是设置渲染目标为画布或者主屏,并进行颜色、深度和模板缓冲区的清除;最后就是提交绘制指令,即进行实际的绘制。

为了实现以上整个驱动的过程,我们首先需要有一个用于渲染的环境,这个环境用于管理Canvas、Context以及一些全局的变量;其次是需要一些资源,这些资源管理着所有渲染所需要的数据;之后还需要一个场景结构和容器,来将这些渲染数据组装管理起来,方便剔除和绘制,与此同时还需要相机来提供观察场景的视角,需要灯光来让场景更加真实;最终还需要一种数据格式用于存储模型数据,还需要对应的加载器来它转换为我们需要的资源数据。

以下内容需要参考文首提到的项目源代码同步观看。

HObject

在真正开始讲述渲染部分的原理之前,要大概说一下整个工程的基本架构。这里我采用的是一个比较简单的继承式OO,对于工程中的每个类,都拥有统一的基类,并遵循统一的模式。这个基类叫做HObject(至于为何是HObject,那当然是因为青年H的原因咯):

export default class HObject {
  public static CLASS_NAME: string = 'HObject';
  public isHObject: boolean = true;
  public name: string;
  get id(): number;
  get hash(): string;
}

每个继承自HObject的类都需要提供static CLASS_NAMEisXXXX,并会通过CLASS_NAME自动生成idhash,还有可选的name方便Debug。

而有了isXXXX这种标示,还可以避免使用instanceof而是使用谓词判断类型,比如:

export default class RenderTexture extends HObject {
  public static IS(value: any): value is RenderTexture {
    return !!value.isRenderTexture;
  }

  public static CLASS_NAME: string = 'RenderTexture';
  public isRenderTexture: boolean = true;
}

便可以通过RenderTexture.IS(obj)来判断是否为obj是否为RenderTexture,无论是运行时还是编译期。

渲染环境

渲染环境在引擎中的实现是core/renderEnv,其实现比较简单,主要是通过Canvas初始化整个WebGPU的Context,同时管理全局Uniform和全局宏,Uniform和宏后面资源的死后会说到,这里先主要关注最重要的init方法实现:

public async init(canvas: HTMLCanvasElement) {
  if (!navigator.gpu) {
    throw new Error('WebGPU is not supported!');
  }

  const adapter = await navigator.gpu.requestAdapter();

  if (!adapter) {
    throw new Error('Require adapter failed!');
  }

  this._device = await adapter.requestDevice();

  if (!this._device) {
    throw new Error('Require device failed!');
  }

  this._canvas = canvas;
  this._ctx = (canvas.getContext('webgpu') as any || canvas.getContext('gpupresent')) as GPUCanvasContext;

  this._ctx.configure({
    device: this._device,
    format: this._swapChainFormat,
  });
}

这里可以看出WebGPU的Context的初始化方式,我们首先要检查navigator上是否有gpu实例,以及gpu.requestAdapter是否能够返回值,然后再看是否能够用adapter.requestDevice分配到设备,这个设备可以认为是图形硬件的一个抽象,其可以把实现和底层硬件隔离,有了这个中间层,便可以做各种兼容和适配。

有了device,还需要一个Context,这可以通过传入的canvas使用getContext('webgpu')获取,获取了Context后,便可以使用ctx.configure来配置SwapChain,注意这里使用到的format一般默认为'bgra8unorm'

场景和容器

根据一开始的论述,读者可能认为接下来就要讲资源的实现,接着才是场景和容器,但这种论述其实是反直觉的。对于了解一个渲染引擎的设计而言,自顶向下才是最优解,下面我会先从场景的组织讲起,然后是渲染的驱动,再到渲染和计算单元,最后才是各个资源的细节。

Node

虽然是自顶向下,但Node,即节点还是要放在最开始说的。由于本文不是3D渲染的科普贴,就不再去说MVP矩阵这种入门知识了。简单来讲,Node就是描述3D场景中某个物体位置信息的基础,其拥有平移pos、旋转quat、缩放scale三个属性,并拥有父级parent和子级children来进行级联,级联后即为树状结构的节点树,这也就是场景的基础:

节点树将会在每一帧驱动的时候通过深度优先搜索dfs来从根节点遍历每个节点,调用updateMatrix进行自顶向下的世界矩阵worldMat更新。成熟的渲染引擎都会在节点树刷新这一步做很多优化,比如脏检查,但本项目没有考虑这些,每帧全量刷新。

节点是非常重要的单元,其也是相机、灯光、渲染单元的基类。

Scene

有了Node,场景Scene就自然而然构成了。所有的渲染引擎基本都有场景的概念,只不过功能可能不同。而本引擎为了方便,直接把渲染驱动的能力给了它。所以本引擎的Scene主要有两个功能——管理场景,以及管理绘制流程。

对于场景的管理主要在于节点树,节点树管理的实现很简单,场景内部持有了一个rootNode,直接将自己新建的节点作为rootNode的子级即可将其加入场景。但场景管理不限于此,在每帧从rootNode自顶向下刷新整棵节点树的时候,我们还可以收集一些渲染必要的信息,比如当帧可供剔除的渲染单元列表meshes当帧需要的灯光列表lights

this._rootNode.updateSubTree(node => {
  if (Mesh.IS(node)) {
    this._meshes.push(node);
  } else if (Light.IS(node)) {
    this._lights.push(node);
  }
});

关于这二者的时候马上就会讲到。

那么现在有了场景,我们如何驱动渲染呢,渲染当然需要一些资源,但也需要流程将它们组织起来。在文章的一开始已经提到过一个渲染流程应该是如何的,那么其实直接将它们翻译成接口给Scene即可:

1.startFrame(deltaTime: number)

一帧开始,执行节点树刷新、收集信息,并设置全局的Uniform,比如u_lightInfosu_gameTime等。然后是最重要的:创建GPUCommandEncoder

this._command = renderEnv.device.createCommandEncoder();

GPUCommandEncoder顾名思义,是WebGPU中用于指令编码的管理器。一般每一帧都会重新创建一个,并在一帧结束时完成它的生命周期。这也是这一代图形API的标准设计,但其实类似思想早就存在于一些成熟的游戏引擎中,比如UE。

2.setRenderTarget(target: RenderTexture | null)

这个接口用于设置渲染目标,如果是RenderTexture资源,那么接下来执行的所有绘制指令都会会知道这个RT上,如果是null则会会知道主屏的缓冲区。

3.cullCamera(camera: Camera): Mesh[]

使用某个相机的camera.cull进行剔除,返回一个Mesh列表,并对列表按照z进行排序。

注意,在标准流程中,透明物体和非透明物体需要分类反着排序,透明物体由远及近画,非透明物体由近及远。并且往往还会加上renderQueue来加上一个可控的排序维度。

4.renderCamera(camera: Camera, meshes: Mesh[], clear: boolean = true)

使用某个相机进行渲染一批Mesh,直接代理到camera.render方法。

5.renderImages(meshes: ImageMesh[], clear: boolean = true)

渲染一批特殊的渲染单元ImageMesh,专用于处理图像效果。

6.computeUnits(units: ComputeUnit[])

对一批计算单元进行计算,专用于处理计算着色器。

7.endFrame()

结束一帧的绘制,主要是将当帧的commandEncoder送入GPUQueue并提交:

renderEnv.device.queue.submit([this._command.finish()]);

如此,this._command便结束了其生命周期,不能再被使用。

Camera

相机是观察整个场景的眼睛,其继承自Node,对渲染的主要贡献有以下几点:

1.剔除:

剔除分为很多种,相机的剔除是物体级别的可见性剔除,其利用相机位置和投影参数构造的视锥体和物体自身求交(出于性能考虑,往往使用包围盒或者包围球)来判定物体是否在可见范围内,并算出相机到物体的距离,来作为后续排序的依据。剔除的实现为camera.cull,但由于其不是教程重点,并且对于目前场景也没什么意义,所以没有实现。不过实现也很简单,读者可以自行查阅相关资料。

2.提供VP矩阵:

相机还需要为渲染过程提供VP矩阵,即视图矩阵ViewMatrix和投影矩阵ProjectionMatrix。前者和相机结点的WorldMatrix相关,后者则和相机的投影模式(透视或者正交)以及参数有关。这些参数将在updateMatrix方法更新了WorldMatrix后计算,计算后会立即设置到全局Uniform中。

当然理论上这种做法在场景中存在多个相机的时候会出问题,实际上应该有个专门的preRender之类的流程来处理,但为了方便这里就这么做吧。

3.天空盒:

除了计算VP矩阵外,在updateMatrix中还会计算一个skyVP,这个是用于天空盒渲染的。天空盒是一种特殊的Mesh,其不会随着相机的移动变换相对位置(只会相对旋转)。本引擎对天空盒的实现很简单,固定使用内置的几何体Geometry,为相机增加了skyboxMat这个材质属性来定制绘制过程,并提供了内置的天空盒材质。

4.渲染:

相机最重要的功能就是进行渲染实际的渲染驱动了,其render(cmd: GPUCommandEncoder, rt: RenderTexture, meshes: Mesh[], clear: boolean)方法就用于此。这个方法的实现并不复杂,直接贴代码:

const [r, g, b, a] = this.clearColor;
const {x, y, w, h} = this.viewport;
const {width, height, colorViews, depthStencilView} = rt;

const renderPassDescriptor: GPURenderPassDescriptor = {
  colorAttachments: colorViews.map(view => ({
    view,
    loadValue: clear ? { r, g, b, a } : 'load' as GPULoadOp,
    storeOp: this.colorOp
  })),
  depthStencilAttachment: depthStencilView && {
    view: depthStencilView,
    depthLoadValue: this.clearDepth,
    stencilLoadValue: this.clearStencil,
    depthStoreOp: this.depthOp,
    stencilStoreOp: this.stencilOp
  }
};

const pass = cmd.beginRenderPass(renderPassDescriptor);
pass.setViewport(x * width, y * height, w * width, h * height, 0, 1);
pass.setBindGroup(0, renderEnv.bindingGroup);

for (const mesh of meshes) {
  mesh.render(pass, rt);
}

if (this.drawSkybox && this._skyboxMesh) {
  this._skyboxMesh.render(pass, rt);
}

pass.endPass();

其中比较重要的是RenderPass的概念,在WebGPU中这可以认为是一次渲染的流程,创建其使用的GPURenderPassDescriptor对象是这个流程要绘制的目标和操作的描述符。可以看到其中主要是设置了颜色和深度/模板缓冲的view,关于这个将会在后面的RenderTexture中详细说到,这里就认为其就是画布即可;而loadValuestoreOp是决定如何清屏的,在这里这些参数都可以交由开发者决定,一般来讲是颜色值store操作。

在创建了一个RenderPass后,就可以设置viewportbindGroup,前者和OpenGL中的视口概念等同,后者可以认为就是UniformBlock,这个在后面会详细讲到,这里设置的是第0个UniformBlock,即全局UB。设置好后便是调用每个Mesh的render方法逐个渲染,最终渲染天空盒。渲染完毕后调用pass.endPass来结束这个RenderPass。

事实上关于ImageMeshComputeUnit的绘制处理也类似,后面会详细讲到。

Mesh

网格Mesh是用于渲染的基本单元,其构造为new Mesh(geometry, material)。其将存储着图元数据的几何体Geometry和决定了渲染方式的材质Material对应组装起来,加之每个对象各有的UniformBlock,在相机绘制的过程中,被调用进行渲染:

public render(pass: GPURenderPassEncoder, rt: RenderTexture) {
  const {_geometry, _material} = this;

  if (_material.version !== this._matVersion || !this._pipelines[rt.pipelineHash]) {
    this._createPipeline(rt);
    this._matVersion = _material.version;
  }

  this.setUniform('u_world', this._worldMat);
  this._bindingGroup = this._ubTemplate.getBindingGroup(this._uniformBlock, this._bindingGroup);

  _geometry.vertexes.forEach((vertex, index) => {
    pass.setVertexBuffer(index, vertex);
  });
  pass.setIndexBuffer(_geometry.indexes, _geometry.indexFormat);
  pass.setBindGroup(1, _material.bindingGroup);
  pass.setBindGroup(2, this._bindingGroup);
  pass.setPipeline(this._pipelines[rt.pipelineHash]);
  pass.drawIndexed(_geometry.count, 1, 0, 0, 0);
}

渲染的流程并不复杂,先不管下面章节会说到的资源的具体实现,从宏观的角度来看,这里主要的工作是设置顶点缓冲vertexBuffer,设置索引缓冲indexBuffer,分别设置物体(index=1)和材质(index=2)级别的Uniform,最后设置一个叫做pipeline的参数,全部设置完后调用drawIndexed来绘制这一批图元。

当然,这也支持GPU实例化,但不在本引擎的讨论范围内。

ImageMesh

图像渲染单元ImageMesh一般用于图像处理,其构造为new ImageMesh(material)。其内置了绘制一张图像的图元数据,直接用传入的Material进行绘制。所以其绘制流程也相对简单,不需要MVP矩阵,所以并不需要相机,也不需要结点,不需要深度缓冲,其他和camera.render几乎一致。

还要注意的是ImageMesh最终的绘制并不是用drawIndexed方法而是用pass.draw(6, 1, 0, 0)方法,因为其并不需要顶点数据,这个在着色器章节会详细论述。

ComputeUnit

WebGPU相对于WebGL最重大的进化之一就是支持计算着色器,计算单元ComputeUnit就用于支持它。和渲染单元不同,其专门用于使用Compute Shader进行计算,相比于渲染单元,其不需要顶点、渲染状态等等数据,所有数据都将视为用于计算的缓冲,所以其构造为new ComputeUnit(effect, groups, values, marcos),效果effect以及valuesmarcos参数也是构造Material的参数,所以ComputeUnit合并了一部分Material的功能。与此同时还加上了groups(类型为{x: number, y?: number, z?: number}),这里先不谈,让我们看看整个一个单元列表的计算是如何实现的:

// 在Scene.ts中
public computeUnits(units: ComputeUnit[]) {
  const pass = this._command.beginComputePass();
  pass.setBindGroup(0, renderEnv.bindingGroup);

  for (const unit of units) {
    unit.compute(pass);
  }

  pass.endPass();
}

// 在ComputeUnit.ts中
public compute(pass: GPUComputePassEncoder) {
  const {_material, _groups} = this;

  if (_material.version !== this._matVersion) {
    this._createPipeline();
    this._matVersion = _material.version;
  }

  pass.setPipeline(this._pipeline);
  pass.setBindGroup(1, _material.bindingGroup);
  pass.dispatch(_groups.x, _groups.y, _groups.z);
}

可以看到,和渲染流程的RenderPass不同,这里创建了一个ComputePass用于这批计算,并且同样需要设置BindingGroup0(可以认为是全局UniformBlock)。之后针对每个计算单元,都设置了pipeline、单元级别的UniformBlock,最后执行dispatch,后面的参数涉及到线程组的概念,和计算着色器也有关,这个后面会讲到。在计算完所有单元后,执行endPass来结束这个流程。

Light

除了相机和渲染单元之外,对于一个最简单的渲染器,灯光也是不可或缺的。本引擎的灯光设计比较简单,全部集中在Light这一个类的实现中。灯光主要属性有类型type、颜色color以及分类型的各种参数,类型一般有:

export enum ELightType {
  INVALID = 0,
  Area,
  Directional,
  Point,
  Spot
}

每种类型的光源都有不同参数,对于本引擎针对的路径追踪而言,比较重要的是面光源的参数:模式(矩形Rect或圆盘Disc)和尺寸宽高或半径。和相机一样,灯光本身也继承自结点,所以在更新矩阵的时候也要计算自己的灯光矩阵LightMatrix,并更新需要送往全局UniformBlock的信息:

public updateMatrix() {
  super.updateMatrix();

  this._ubInfo.set(this._color, 4);
  this._ubInfo.set(this._worldMat, 8);
  this._ubInfo.set(mat4.invert(new Float32Array(16), this._worldMat), 24);
}

在实际渲染中,renderEnv.startFrame中全局UB的u_lightInfos就是从这个ubInfo中获取信息更新的,目前一个物体一次绘制最多支持四个灯光。

资源

有了宏观的渲染管线和容器,就只剩具体的资源来填充它们了。在接下来的章节,我将论述我们熟悉的一些渲染资源抽象如何在WebGPU中实现。

Shader

首先必须要说的是着色器Shader,因为后续的所有资源最终都会在Shader中被应用,并且它们的部分设计和Shader也有着十分紧密的联系。着色器是什么我不再赘述,相信写过OpenGL或WebGL的读者都写过顶点着色器片段着色器,在WebGPU中它们同样存在,而比起WebGL,WebGPU还实现了计算着色器

WebGPU使用的着色器语言在一次又一次的变更后,终于定论为WGSL。其语法和glslhlsl都有较大差异,以本人的观点看比较像融合了metal和rust的很多部分,这一点也体现在编译阶段——WGSL的类型系统十分严格。

本文并非是一个WGPU或者WGSL的详细教程(否则至少得写一本书),下面就分别以三个/三种着色器的简单示例,来论述一下本文需要用到的一些WGSL的基本知识。

顶点着色器

//model.vert.wgsl
struct VertexOutput {
  [[builtin(position)]] Position: vec4<f32>;
  [[location(0)]] texcoord_0: vec2<f32>;
  [[location(1)]] normal: vec3<f32>;
  [[location(2)]] tangent: vec4<f32>;
  [[location(3)]] color_0: vec3<f32>;
  [[location(4)]] texcoord_1: vec2<f32>;
};

[[stage(vertex)]]
fn main(attrs: Attrs) -> VertexOutput {
  var output: VertexOutput;

  output.Position = global.u_vp * mesh.u_world * vec4<f32>(attrs.position, 1.);

  #if defined(USE_TEXCOORD_0)
    output.texcoord_0 = attrs.texcoord_0;
  #endif

  #if defined(USE_NORMAL)
    output.normal = attrs.normal;
  #endif

  #if defined(USE_TANGENT)
    output.tangent = attrs.tangent;
  #endif

  #if defined(USE_COLOR_0)
    output.color_0 = attrs.color_0;
  #endif

  #if defined(USE_TEXCOORD_1)
    output.texcoord_1 = attrs.texcoord_1;
  #endif

  return output;
}

这是一个典型的顶点着色器,首先从其入口main看起,[[stage(vertex)]]表明这是一个顶点着色器的入口,attrs: Attrs表明其输入是一个类型为Attrs的struct,-> VertexOutput则说明顶点着色器要传输给下一步插值的数据类型为VertexOutputAttrs在这里没有写,这是因为其相对动态,我将其生成集成到了整个渲染过程的设计中,这个在下面的Geometry章节会讲到。而VertexOutput的定义就在代码顶部,其是一个struct,里面的内容都是形如[[位置]] 名字: 类型的形式,唯一不同的是[[builtin(position)]] Position,因为位置信息是重心坐标插值的重要依据,所以需要特殊指明。

接下来main的函数体中,主要实现了顶点数据的计算和输出,其中包括大家熟悉的MVP变换output.Position = global.u_vp * mesh.u_world * vec4<f32>(attrs.position, 1.);,这里要注意var output: VertexOutput的定义中使用的是var,在WGSL中使用varlet区分变量是动态还是静态,静态变量不可变并且必须初始化,动态的可变。在结尾return output表明返回计算结果到下一个阶段。

注意这里出现了#if defined(USE_TEXCOORD_0)这样的写法,而我们也在前面提到过,但实际上WGSL目前并没有实现宏、或者在这里的功能层面为预处理器,详见Issue[wgsl Consider a preprocessor。这里其实是我自己通过正则实现的一个简易预处理器,除此之外我还是用webpack的loader机制实现了一个简单的require方法,来完成shader文件的分隔复用,这里不再赘述。

这里还有必要单独提一下ImageMesh使用的顶点着色器,在上面的章节中提到了其绘制并不依赖于顶点数据,其实是利用WGSL的模块作用域变量(MODULE SCOPE VARIABLE)实现的:

struct VertexOutput {
  [[builtin(position)]] position: vec4<f32>;
  [[location(0)]] uv: vec2<f32>;
};

var<private> pos: array<vec2<f32>, 6> = array<vec2<f32>, 6>(
  vec2<f32>(-1.0, -1.0),
  vec2<f32>(1.0, -1.0),
  vec2<f32>(-1.0, 1.0),
  vec2<f32>(-1.0, 1.0),
  vec2<f32>(1.0, -1.0),
  vec2<f32>(1.0, 1.0)
);
var<private> uv: array<vec2<f32>, 6> = array<vec2<f32>, 6>(
  vec2<f32>(0.0, 1.0),
  vec2<f32>(1.0, 1.0),
  vec2<f32>(0.0, 0.0),
  vec2<f32>(0.0, 0.0),
  vec2<f32>(1.0, 1.0),
  vec2<f32>(1.0, 0.0)
);

[[stage(vertex)]]
fn main([[builtin(vertex_index)]] VertexIndex : u32) -> VertexOutput {
  var output: VertexOutput;

  output.position = vec4<f32>(pos[VertexIndex], 0.0, 1.0);
  output.uv = uv[VertexIndex];

  #if defined(FLIP)
    output.uv.y = 1. - output.uv.y;
  #endif

  return output;
}

这里通过var<private>定义了两个模块作用域的变量数组posuv,可以被着色器函数索引内容,而此处函数入参[[builtin(vertex_index)]] VertexIndex : u32是当前正在绘制的顶点索引,用这个索引和两个数组即可完成输出。

模块作用域变量不止有private一个域,还有workgroup等,这里不再赘述。

片段着色器

struct VertexOutput {
  [[builtin(position)]] Position: vec4<f32>;
  [[location(0)]] texcoord_0: vec2<f32>;
  [[location(1)]] normal: vec3<f32>;
  [[location(2)]] tangent: vec4<f32>;
  [[location(3)]] color_0: vec3<f32>;
  [[location(4)]] texcoord_1: vec2<f32>;
};

[[stage(fragment)]]
fn main(vo: VertexOutput) -> [[location(0)]] vec4<f32> {
  return material.u_baseColorFactor * textureSample(u_baseColorTexture, u_sampler, vo.texcoord_0);
}

main函数上方的[[stage(fragment)]]表明这是一个片段着色器。这个着色器很简答也很典型,其将顶点着色器的输出VertexOutput重心插值后的结果作为输入,最终输出一个 [[location(0)]] vec4<f32>的结果,这个结果就是最终这个片元颜色。

有了结构,再看看函数体中做了什么。这里从一个叫material的变量中取出了u_baseColorFactor,并用textureSample方法和UVvo.texcoord_0,对名为u_baseColorTexture的贴图进行了采样,并且还用上了一个叫做u_sampler的变量。纹理采样我们熟悉,但这个materiau_baseColorTextureu_sampler又是什么呢?这就要涉及到WGSL的另一点:BindingGroup了

BindingGroup

这不是本文第一次出现BindingGroup这个词,在前面我们已经提到过无数次,并将其和UniformBlock相提并论,并在绘制流程中调用了pass.setBindGroup方法。那么这东西到底是什么呢?回想一下我们在OpenGL或者WebGL中如何在渲染过程中更新uniform的信息,一般是调用类似gl.uniform4vf这种借口,来将从shader反射信息拿到的location作为key,把值更新上去,如果是纹理则还需要线绑定纹理等等操作。就算是加上了缓存,每帧用于更新uniform的时间也是可观的,所以新一代图形API为了解决这个问题,就给出了BindingGroup或者类似的方案。

BindingGroup本质上可以看做是一个uniform的集合,和其他资源一样,在WebGPU最后创建一个BindingGroup也需要一个描述符:

device.createBindGroup(descriptor: {layout: GPUBindGroupLayout; entries: Iterable<GPUBindGroupEntry>}): GPUBindGroup;

描述符需要一个layout和一个entries,前者给出了结构,后者给出了实际的。以一个简单的构造为例:

const visibility = GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT;
const bindingGroup = renderEnv.device.createBindGroup({
  layout: device.createBindGroupLayout({entries: [
    {
      binding: 0, visibility,
      buffer: {type: 'uniform' as GPUBufferBindingType},
    },
    {
      binding: 1, visibility,
      texture: {
        sampleType: 'rgba8unorm',
        viewDimension: '2d'
      },
    },
    {
      binding: 2, visibility,
      sampler: {type: 'filtering'}
    },
    {
      binding: 3,
      visibility: GPUShaderStage.COMPUTE,
      buffer: {type: 'storage'}
    },
  ]}),
  entries: [
    {
      binding: 0,
      resource: {buffer: gpuBuffer}
    },
    {
      binding: 1,
      resource: textureView
    },
    {
      binding: 2,
      resource: sampler
    },
    {
      binding: 3,
      resource: {buffer}
    }
  ]
});

这里构建的BindingGroup涵盖了几种值,分别是Uniform、Texture、Sampler和Storage,可见每种方式的描述定义都不一样,但它们都有共同之处——需要制定binding(可以认为是这个Group下的子地址)和visibility(在哪种着色器可见)。而这里构建的BindingGroup最终会设置到Pass中指定地址给Shader使用,在Shader中我们同样需要定义它们的结构:

[[block]] struct UniformsMaterial {
 [[align(16)]] u_baseColorFactor: vec4<f32>;
};
[[group(2), binding(0)]] var<uniform> material: UniformsMaterial;
[[group(2), binding(1)]] var u_baseColorTextures: texture_2d<f32>;
[[group(2), binding(2)]] var u_sampler: sampler;

struct Debug {
  origin: vec4<f32>;
  dir: vec4<f32>;
};
[[block]] struct DebugInfo {
  rays: array<DebugRay>;
};[[group(2), binding(3)]] var<storage, read_write> u_debugInfo: DebugInfo;

这里将一个BindingGroup分为了几个部分,所有的向量、矩阵等uniform类型的数据被打包在UniformsMaterial这个struct中,texture、sampler和storage类型的数据则需要分离出来,并且都有各自的定义形式,这个就是为何我们上面要通过materia.u_baseColorFactor去取uniform数据,而直接用u_texture来取纹理数据。

还需要注意的是各种前缀,group(2)表明这是绑定的第2个BindingGroup,这和前面说过的全局、单元、材质的UniformBlock是绑定在不同级别的一致。事实上前面也给出过其他两个级别的例子,在顶点着色器中使用到的global.u_vp * mesh.u_world就是在全局0和单元1级别的。而后面的binding(x)则需要和ts中的声明一致。同时还要注意的是uniform类型的struct的align(16),这是为了强制每一项16字节对齐,同时可能使用的还是stride这样的修饰。

由于BindingGroup的创建和Shader描述耦合十分高,其中还有字节对齐之类的问题,为了方便使用、减少冗余代码,在引擎中我将BindingGroup的构造、Shader对应的struct的定义生成、Uniform管理都深度融合了,将在下面的UBTemplate论述。

计算着色器

计算着色器和前两种都不同,其不属于渲染管线的一部分,利用的是GPU的通用计算能力,所以也没有顶点输入、像素输出之类的要求,下面是一个比较糙的对图像卷积滤波的例子:

let c_radius: i32 = ${RADIUS};
let c_windowSize: i32 = ${WINDOW_SIZE};

[[stage(compute), workgroup_size(c_windowSize, c_windowSize, 1)]]
fn main(
  [[builtin(workgroup_id)]] workGroupID : vec3<u32>,
  [[builtin(local_invocation_id)]] localInvocationID : vec3<u32>
) {
  let size: vec2<i32> = textureDimensions(u_input, 0);
  let windowSize: vec2<i32> = vec2<i32>(c_windowSize, c_windowSize);
  let groupOffset: vec2<i32> = vec2<i32>(workGroupID.xy) * windowSize;
  let baseIndex: vec2<i32> = groupOffset + vec2<i32>(localInvocationID.xy);
  let baseUV: vec2<f32> = vec2<f32>(baseIndex) / vec2<f32>(size);

  var weightsSum: f32 = 0.;
  var res: vec4<f32> = vec4<f32>(0., 0., 0., 1.);
  for (var r: i32 = -c_radius; r <= c_radius; r = r + 1) {
    for (var c: i32 = -c_radius; c <= c_radius; c = c + 1) {
      let iuv: vec2<i32> = baseIndex + vec2<i32>(r, c);

      if (any(iuv < vec2<i32>(0)) || any(iuv >= size)) {
        continue;
      }

      let weightIndex: i32 = (r + c_radius) * c_windowSize + (c + c_radius);
      let weight: f32 = material.u_kernel[weightIndex / 4][weightIndex % 4];
      weightsSum = weightsSum + weight;
      res = res + weight * textureLoad(u_input, iuv, 0);
    }
  }
  res = res / f32(weightsSum);

  textureStore(u_output, baseIndex, res);
}

main函数的修饰stage(compute)表明这是一个计算着色器,除此之外后面还有workgroup_size(c_windowSize, c_windowSize, 1),读者应该注意到了这里有三个维度的参数,还记得我们前面在论述ComputeUint时提到的groupSize以及pass.dispatch(x, y, z)的三个参数吗?它们毫无疑问是有关联的,这就是线程组的概念。

这里不再赘述卷积滤波的原理,只需要知道它每次要对一个N X N窗口内的像素做处理,所以这里定义了一个窗口大小windowSize、也就定义了一个二维的(第三个维度为1)、大小为size = windowSize X windowSize的线程组,这表明一个线程组有这么大数量的线程,如果我们要处理纹理数据、同时每个线程处理一个像素,那这么一个线程组可以处理size个数量的像素,那么如果我们要处理一张width x height大小的图片,就应当将groupSize设置(width / windowSize, height / windowSize, 1)

不同平台上允许的最大线程数量有限制,但一般都应当是16 x 16以上。

除此之外,可以看到计算着色器确实没有顶点像素之类的概念,但它有输入workgroup_idlocal_invocation_id,这其实就是线程组的偏移线程组内线程的偏移,通过它们我们就可以得到真正的线程偏移,也可以作为纹理采样的依据。能够采样纹理,就可以进行计算,最后将计算的结果通过textureStore写回输出纹理即可,输出纹理是一个write类型的storageTexture

着色器中的${WINDOW_SIZE}这种也属于我自己实现的简陋的宏的一部分。

Geometry

几何体Geometry本质上就是顶点数据和索引数据的集合。在OpenGL中,我们往往会自己组织一个结构来描述顶点数据的存储构造,在WebGPU中,API标准即将这个结构定死了,其为GPUVertexBufferLayout[]。这个结构的描述加上vertexBufferindexBuffer即构成了整个渲染单元的几何信息:

constructor(
  protected _vertexes: {
    layout: {
      attributes: (GPUVertexAttribute & {name: string})[],
      arrayStride: number
    },
    data: TTypedArray,
    usage?: number
  }[],
  protected _indexData: Uint16Array | Uint32Array,
  public count: number,
  protected _boundingBox?: IBoundingBox
) {
  this._iBuffer = createGPUBuffer(_indexData, GPUBufferUsage.INDEX);
    this._vBuffers = new Array(_vertexes.length);
    this._vLayouts = new Array(_vertexes.length);
    this._indexFormat = _indexData instanceof Uint16Array ? 'uint16' : 'uint32';
    this._vInfo = {};
    this._marcos = {};
    this._attributesDef = 'struct Attrs {\n';

    _vertexes.forEach(({layout, data, usage}, index) => {
      const vBuffer = createGPUBuffer(data, GPUBufferUsage.VERTEX | (usage | 0));

      layout.attributes.forEach((attr) => {
        this._marcos[`USE_${attr.name.toUpperCase()}`] = true;
        this._attributesDef += `  [[location(${attr.shaderLocation})]] ${attr.name}: ${this._convertFormat(attr.format)};\n`;
        this._vInfo[attr.name.toLowerCase()] = {
          data, index,
          offset: attr.offset / 4, stride: layout.arrayStride / 4, length: this._getLength(attr.format)
        };
      });

      this._vBuffers[index] = vBuffer;
      this._vLayouts[index] = layout;

      this._vertexCount = data.byteLength / layout.arrayStride;
    });

    this._attributesDef += '};\n\n';
}

这是Geometry的构造方法,可以看到其主要是将多个vertexBuffer以及对应的layout、一个indexBuffer、顶点个数count处理,最终生成WebGPU需要的VertexLayoutGPUBuffer,同时还生成了其他必要的数据。VertexLayout这是描述无须赘述,这里主要需要注意GPUBuffer和所谓其他数据的生成。

GPUBuffer

首先是GPUBuffer,其在WebGPU中很常见,除了纹理之外所有在CPU和GPU传输的数据都是GPUBuffer。在历经数次迭代后,我们可以用一段简短的代码来创建它:

export function createGPUBuffer(array: TTypedArray, usage: GPUBufferUsageFlags) {
  const size = array.byteLength + (4 - array.byteLength % 4);
  const buffer = renderEnv.device.createBuffer({
    size,
    usage: usage | GPUBufferUsage.COPY_DST,
    mappedAtCreation: true
  });

  const view = new (array.constructor as {new(buffer: ArrayBuffer): TTypedArray})(buffer.getMappedRange(0, size));
  view.set(array, 0);

  buffer.unmap();

  return buffer;
}

这段代码通过传入的TypedArray创建一个同尺寸(但需要字节对齐)的GPUBuffer,并将其数据的值在初始化的时候拷贝给它。

宏和Shader

在创建Geometry的过程中还会生成别的重要数据:

  1. 一张宏的表_marcos,所有用到的顶点属性都会以USE_XXX的形式存在,然后作用在Shader中,比如上面顶点着色器示例那样。
  2. 顶点相关的Shader类型定义数据_attributesDef,自动组装出需要的拼接到Shader头部的字符串。

Texture

前面提到了BindingGroup的各种类型,其中有一大类就是纹理Texture,而纹理在引擎实现中又可以分为2D纹理和Cube纹理。

2D纹理

纹理在WebGPU中的创建很简单:

this._gpuTexture = renderEnv.device.createTexture({
  label: this.hash,
  size: {width: this._width, height: this._height, depthOrArrayLayers: this._arrayCount},
  format: _format || 'rgba8unorm',
  usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST | GPUTextureUsage.RENDER_ATTACHMENT
});

if (isTextureSourceArray(_src)) {
  _src.forEach((src, index) => this._load(src, index));
  this._gpuTextureView = this._gpuTexture.createView({dimension: '2d-array', arrayLayerCount: this._arrayCount});
} else {
  this._load(_src);
  this._gpuTextureView = this._gpuTexture.createView();
}

首先我们需要用device.createTexture来创建一个纹理,这里需要注意的参数是size中的depthOrArrayLayers,其在类型为2d-array的时候是数组的数量。创建了纹理后用_load方法来加载并上传纹理数据,最终调用createView方法创建view并缓存。而对于_load的实现,则根据来源是Buffer或图像有不同的做法:

_loadImg(img: ImageBitmap, layer: number) {
  renderEnv.device.queue.copyExternalImageToTexture(
    {source: img},
    {texture: this._gpuTexture, origin: this._isArray ? {x: 0, y: 0, z: layer} : undefined},
    {width: this._width, height: this._height, depthOrArrayLayers: 1}
  );
}

_loadBuffer(buffer: ArrayBuffer, layer: number) {
  renderEnv.device.queue.writeTexture(
    {texture: this._gpuTexture, origin: this._isArray ? {x: 0, y: 0, z: layer} : undefined},
    buffer as ArrayBuffer,
    {bytesPerRow: this._width * 4},
    {width: this._width, height: this._height, depthOrArrayLayers: 1}
  );
}

通过图像提供纹理数据,主要使用device.queue.copyExternalImageToTexture方法,但要求传入的是一个ImageBitMap,这个可以使用以下代码生成:

const img = document.createElement('img');
img.src = src;
await img.decode();
const bitmap = await createImageBitmap(img);

而使用Buffer的话,则直接用device.queue.writeTexture即可。

Cube纹理

Cube纹理和2D纹理大差不差,区别在于其初始化的时候depthOrArrayLayers为6,并且需要在初始化的时候提交六张纹理,并且在origin参数中的z为1~6。

RenderTexture

说完Texture,顺便可以直接说说可渲染纹理RenderTexture,顾名思义,这是一种可以用于用于绘制或写入数据的纹理。和通常的RenderTexture的设计一样,为了和MRT(多渲染目标)技术相容,本引擎的RenderTexture也设计为多个colorTexture和一个depthTexture的形式,来看看它的构造参数:

export interface IRenderTextureOptions {
  width: number;
  height: number;
  forCompute?: boolean;
  colors: {
    name?: string,
    format?: GPUTextureFormat
  }[];
  depthStencil?: {
    format?: GPUTextureFormat;
    needStencil?: boolean;
  };
}

具体的实现不贴了,就是根据colorsdepthStencil中的参数去创建不同的GPUTexture,然后使用每个colorname建表索引,之后创建view缓存。这里有个特别的参数forCompute(是否用于计算着色器),主要决定了创建GPUTexture时的usage,如果是则需要开启GPUTextureUsage.STORAGE_BINDING

创建好的RenderTexture可以和Texture直接一样直接用于渲染来源数据,但其最重要的功能是用于绘制,关于如何绘制,已经在前面的Camera章节说过了,将其按照顺序设置到GPURenderPassDescriptor即可。

UBTemplate

UBTemplate,可以认为是前面提了无数次的UniformBlock的模板,当然到这里读者应该察觉到了——这个UniformBlock远不止管理了Uniform,也管理了纹理、采样器、SSBO等等,只不过出于习惯这么称呼。而这也可以认为是整个引擎最复杂的一部分,因为它不仅涉及到了这些数据的创建和更新,还涉及到了Shader相关定义的生成。而在WebGPU和WGSL规范中,尤其是Uniform部分的规范又非常繁琐,比如各种字节对齐(align、stride),在方便使用的前提下,UBTemplate需要自动抹平这些复杂性,对外暴露足够简单的构造和接口,当然这是有代价的——会造成部分的内存浪费。

将UBTemplate所做的全部工作列出来实在会篇幅过长,而且必要性不大,这里就说一些核心的地方,剩下的读者自己去看代码即可。首先让我们看一下它的构造参数:

constructor(
  protected _uniformDesc: IUniformsDescriptor,
  protected _groupId: EUBGroup,
  protected _visibility?: number,
);

export enum EUBGroup {
  Global = 0,
  Material = 1,
  Mesh = 2
}

export type TUniformValue = TUniformTypedArray | Texture | CubeTexture | GPUSamplerDescriptor | RenderTexture;

export interface IUniformsDescriptor {
  uniforms: {
    name: string,
    type: 'number' | 'vec2' | 'vec3' | 'vec4' | 'mat2x2' | 'mat3x3' | 'mat4x4',
    format?: 'f32' | 'u32' | 'i32',
    size?: number,
    customType?: {name: string, code: string, len: number},
    defaultValue: TUniformTypedArray
  }[],
  textures?: {
    name: string,
    format?: GPUTextureSampleType,
    defaultValue: Texture | CubeTexture,
    storageAccess?: GPUStorageTextureAccess,
    storageFormat?: GPUTextureFormat
  }[],
  samplers?: {
    name: string,
    defaultValue: GPUSamplerDescriptor
  }[],
  storages?: {
    name: string,
    type: 'number' | 'vec2' | 'vec3' | 'vec4',
    format?: 'f32' | 'u32' | 'i32',
    customStruct?: {name: string, code: string},
    writable?: boolean,
    defaultValue: TUniformTypedArray,
    gpuValue?: GPUBuffer
  }[]
}

可见主要是_uniformDesc_groupId,后者很简单,决定UB将用于哪个级别,这决定生成的Shader定义中Uniform部分的是globalmesh还是material。而前者就相对复杂了,其主要是四个部分:

  1. uniforms:Uniform部分,这一部分的数据一般都是细粒度的向量、矩阵等,支持数组,在实际生成最后,会将他们都打包成一个大的Buffer,这个Buffer是严格16字节对齐的,这是什么意思呢?比如一个Vector3数组,将会被生成为[[align(16)]] ${name}: [[stride(16)]] array<vec3<f32>, 4>;。也就是说虽然这个数据只占用4 x 3 x 4个字节的空间即可描述,但这里强制使其占用4 x 4 x 4的空间,在每个vec3元素后都填了一位。在使用setUniform设置值的时候也会自动按照这个对齐规则来设置。
  2. textures:纹理部分,和前面给出的创建BindingGroup时使用基本一致,但注意这里有参数storageAccessstorageFormat,用于生成storageTexture的定义,一般用于给CS提供可写入的RenderTexture。
  3. samplers:采样器部分,和前面给出的创建BindingGroup时使用完全一致。
  4. storages:SSBO部分,这是一种可以在CPU和GPU共享的特殊Buffer,其可以存储相对大量的数据,并可以在GPU的CS中写入和读取、也可以在CPU中写入和读取。在本项目中我一般用于调试CS。

uniforms和storage都提供了customStructcustomType参数来让用户自定义结构,而非默认生成,提供了自由度。

每次创建UBTemplate的时候,实际上都只是生成了BindingGroup中的layout部分和CPU端的默认值等,于此同时还生成了Shader中对应的Uniform相关的定义字符串。而在后续实际渲染中,我们将使用createUniformBlock方法实际创建UB时,返回的是:

export interface IUniformBlock {
  isBufferDirty: boolean;
  isDirty: boolean;
  layout: GPUBindGroupLayout;
  entries: GPUBindGroupEntry[];
  cpuBuffer: Uint32Array;
  gpuBuffer: GPUBuffer;
  values: {
    [name: string]: {
      value: TUniformValue,
      gpuValue: GPUBuffer | GPUSampler | GPUTextureView
    }
  };
}

后续便可以使用setUniform(ub: IUniformBlock, name: string, value: TUniformValue, rtSubNameOrGPUBuffer?: string | GPUBuffer);方法和getUniform(ub: IUniformBlock, name: string)给用到UBTemplate的对象提供修改和获取的支持。还记得前面说到的几个UniformBlock吗?其实就是用UBTemplate生成的,其中renderEnv管理了全局的UniformBlock,Mesh/ImageMesh/ComputeUint管理了单元级别的UniformBlock,而即将说到的Material管理了材质级别的UniformBlock,它们被设置到不同的地址,共同完成渲染。

在最终,也就是后续会提到的创建BindingGroup的那一步,使用的是getBindingGroup方法,在上面说到的几类对象上都有bindingGroup访问器代理到这里:

public getBindingGroup(ub: IUniformBlock, preGroup: GPUBindGroup) {
  if (ub.isBufferDirty) {
    renderEnv.device.queue.writeBuffer(
      ub.gpuBuffer as GPUBuffer,
      0,
      ub.cpuBuffer
    );

    ub.isBufferDirty = false;
  }

  if (ub.isDirty) {
    preGroup = renderEnv.device.createBindGroup({
      layout: ub.layout,
      entries: ub.entries
    });
    ub.isDirty = false;
  }

  return preGroup;
}

可见这里主要做了两件事,第一件事是检查脏位更新Uniform部分的数据,第二则是检查并更新group缓存返回。

Effect

效果Effect可以认为是对Shader、UniformBlock、渲染状态和宏的一个管理器,也可以认为是一个模板,以供后面的Material实例化,其构造参数为:

export interface IRenderStates {
  cullMode?: GPUCullMode;
  primitiveType?: GPUPrimitiveTopology;
  blendColor?: GPUBlendComponent;
  blendAlpha?: GPUBlendComponent;
  depthCompare?: GPUCompareFunction;
}

export interface IEffectOptionsRender {
  vs: string;
  fs: string;
  uniformDesc: IUniformsDescriptor;
  marcos?: {[key: string]: number | boolean};
  renderState?: IRenderStates;
}
export interface IEffectOptionsCompute {
  cs: string;
  uniformDesc: IUniformsDescriptor;
  marcos?: {[key: string]: number | boolean};
}
export type TEffectOptions = IEffectOptionsRender | IEffectOptionsCompute;

除去vs/fs(用于渲染)和cs(用于计算)的区分,通用的部分是:

  1. UBTemplate构造参数uniformDesc:指定这个Effect提供的默认UniformBlock结构来创建UBTemplate。
  2. 宏对象marcos:Effect能够支持的宏特性列表,后续生成Shader的时候会使用。
  3. 渲染状态renderState:Effect提供的默认渲染状态,这里只定义了几个我用到过的,实际上还有不少。

Effect的功能并不多,其最重要的是对外暴露了createDefaultUniformBlockgetShader两个方法,以供最后的渲染使用。前者将会在Material中用到,后者则会在最后的Pipeline中用到。

Material

材质Material可以看做是实例化后的Effect,其构造如下:

constructor(
  protected _effect: Effect,
  values?: {[name: string]: TUniformValue},
  marcos?: {[key: string]: number | boolean},
  renderStates?: IRenderStates
) {
  super();

  this._uniformBlock = _effect.createDefaultUniformBlock();

  if (values) {
    Object.keys(values).forEach(name => this.setUniform(name, values[name]));
  }

  this._marcos = marcos || {};
  this._renderStates = renderStates || {};
}

可见其实很简单,就是利用拥有的Effect构建了一个材质级别的UniformBlock并设置初始值,然后提供了宏marcos和渲染状态renderState,后续也可以修改和获取这些宏和渲染状态。需要注意的是,Material还提供了version(number)类型,来记录版本,以便于后续Pipeline的更新。

Pipeline

讲了这么多,基本所有的要素都极其了,终于到了整个流程的最后一步——管线Pipeline。Pipeline的创建是分别实现在MeshImageMeshComputeUint中的,也就是前面在论述这三者时提到的_createPipeline方法,如果读者还记得,其实这里就利用了上面说到的material.version来判断版本做缓存。这是因为和BindingGroup一样,创建Pipeline的开销并不低。

在Mesh中,创建的实现为:

const {device} = renderEnv;
const {_geometry, _material, _ubTemplate} = this;

this._bindingGroup = this._ubTemplate.getBindingGroup(this._uniformBlock, this._bindingGroup);
const marcos = Object.assign({}, _geometry.marcos, _material.marcos);
const {vs, fs} = _material.effect.getShader(marcos, _geometry.attributesDef, renderEnv.shaderPrefix, _ubTemplate.shaderPrefix);

this._pipelines[rt.pipelineHash] = device.createRenderPipeline({
  layout: device.createPipelineLayout({bindGroupLayouts: [
    renderEnv.uniformLayout,
    _material.effect.uniformLayout,
    _ubTemplate.uniformLayout
  ]}),

  vertex: {
    module: vs,
    entryPoint: "main",
    buffers: _geometry.vertexLayouts
  },

  fragment: {
    module: fs,
    targets: rt.colorFormats.map(format => ({
      format,
      blend: _material.blendColor ? {
        color: _material.blendColor,
        alpha: _material.blendAlpha
      } : undefined
    })),
    entryPoint: "main"
  },

  primitive: {
    topology: _material.primitiveType,
    cullMode: _material.cullMode
  },

  depthStencil: rt.depthStencilFormat && {
    format: rt.depthStencilFormat,
    depthWriteEnabled: true,
    depthCompare: _material.depthCompare
  }
});

可见这里将之前所有部分基本都串起来了,首先合并两个级别的宏,通过他们和Geometry的顶点信息Shader定义、三个级别的UniformBlock的Shader定义来生成最终的vsfs,然后使用device.createRenderPipeline方法将这一切都组装起来,生成最终的Pipeline。

ImageMesh和上面基本一致,只不过省去了不需要的vertex部分,而ComputeUint由于没有顶点、片元,也没有渲染状态和渲染目标,更为简单:

 protected _createPipeline() {
  const {device} = renderEnv;
  const {_material} = this;

  const marcos = Object.assign({}, _material.marcos);
  const {cs} = _material.effect.getShader(marcos, '', renderEnv.shaderPrefix, '');

  this._pipeline = device.createComputePipeline({
    layout: device.createPipelineLayout({bindGroupLayouts: [
      renderEnv.uniformLayout,
      _material.effect.uniformLayout
    ]}),

    compute: {
      module: cs,
      entryPoint: "main"
    }
  });
}

至此,整个渲染引擎部分就此结束。

glTF和工作流

本来这里想顺便说说资源和工作流部分的,但篇幅已经太长了,就放在下一个章节讲吧。

【WebGPU实时光追美少女】概览介绍

作者 dtysky
2021年9月6日 11:00

""

本系列文章将会论述如何用WebGPU来实现一个实时路径追踪渲染器,从一个简单渲染器为开端层层深入,了解经典路径追踪渲染器的各个部分,以及BRDF模型在路径追踪中的实现。

Github仓库在这里:dtysky/webgpu-renderer,Demo在:Demo,注意目前需要最新的Chrome Canary版本才能打开。

这个项目是我三个月前一边学习闫神的GAMES202一边搞的,当时搞了大半的进度后迫于搞给自己庆生的游戏Double;14所以暂时搁置,游戏搞完了肝也废了缓了一阵子又出了一些头疼现实问题,最近都解决了才重新捡起来。

大纲

下面这些是这系列教程的一个大纲,都是通过GAMES101和202相关章节、阅读资料以及自己的理解做出来的,由于个人水平有限,所以难免有错漏之处,欢迎指出。同时迫于毕业时许诺的“三十岁之前一定要出一个商业品质的独立游戏,而现在只有两年时间了”以及做好可能RUN的准备带来的焦虑,所以这系列文章主要目的是基于现有的进度尽快收尾而非达到最优化,所以下面章节有的部分可能并不会完美实现,这些部分我会特别注明。

这个系列的文章预计如下:

  1. 概览介绍:本章节,将会对整个项目涉及的知识做一个综述。
  2. WebGPU基础与简单渲染器:通过自己实现的一个简单渲染器来论述WebGPU的能力。
  3. 路径追踪-场景数据组织:针对路径追踪,如何组织场景数据,涉及到PBR材质、glTF、场景合并等。
  4. 路径追踪-管线组织与GBuffer:针对路径追踪,如何组织渲染管线,同时论述GBuffer的生成。
  5. 路径追踪-BVH构建与求交:如何构建BVH,以及如何在CS中求交。
  6. 路径追踪-随机采样与BRDF:论述如何在路径追踪中运用蒙特卡洛采样实现直接光照和间接光照,以及运用BRDF光照模型。
  7. 路径追踪-降噪与色调映射:如何对充满噪点的图像进行空间和时间的滤波,最后输出如何进行色调映射。
  8. 路径追踪-毛玻璃材质与BSDF:如何运用BSDF(BRDF+BTDF)模型实现毛玻璃材质。
  9. 踩坑与调试心得:对于WebGPU这样一个实验性的API(至少当时),我是如何进行调试的血泪史(主要是CS部分)。
  10. 性能优化:包括BVH并行构建、WebGPU管线优化等。

在这其中,章节7的降噪部分,目前大概率只会涉及到静态场景的简单滤波,而不会有Motion VectorSVGF之类的进阶实现;章节8的BSDF至今仍未实现,并且不一定会实现,但关于透射整个框架已经搭好,会尽量做完吧;章节10的性能优化大概率不做了,如果梦想推进顺利可能会在未来某一天忽然补完。

(以上其实就是风险声明,烂尾别找我(逃

概览介绍

本项目主要分为两部分,第一是WebGPU部分,第二是路径追踪部分。

WebGPU

实时渲染在工业领域的应用离不开各个图形API,主流的API主要被各个系统厂商和标准委员会(实际上背后也是厂商)所支持,比如过去的OpenGL,OpenGL是一个跨平台的图形API,其迭代了漫长的岁月并在这些岁月中占据了这个领域大部分份额。在十年前,Web作为一个通用的平台也支持了图形接口,其名为WebGL,是OpenGL ES2在Web上的一个实现,在当时为Web带来了3D的可能。

但这也是十年前的事情了,在今天,图形API也遵循了“分久必合、合久必分”的规律,分裂成了DX12、Metal和Vulkan,这些API都更加底层、给开发者提供了更深层的可控性(当然同时也带来了更高的开发难度),相比于OpenGL,可谓是“自动挡”和“手动挡”的区别。而反观Web平台,这十年来却似乎毫无长进,在OpenGL迭代了如此多的版本后仍然停留在ES2的时代,只能打些扩展补丁缝缝补补,这当然很大一部分苹果不支持WebGL2的锅(虽然宣布将支持,但太晚了)。但苹果虽然在支持WebGL2上摸鱼,却也同时推进了更激进的下一代Web图形接口方案——WebGPU。关于WebGPU标准这几年的不断延期、标准的不断变更、API直到近两个月还在改、Shader变了一次又一次的槽就不吐了,总之,作为Web开发者的我们终于也有对标DX、Metal、Vulkan的图形API了,而事实上其API设计确实是借鉴了它们,并且用起来也足够简单。

值得欣慰的是,谷歌宣布Chrome 94的正式版将会正式支持WebGPU。

WebGPU标准目前的状态基本已经稳定,可见WebGPU标准。同时其使用的着色器也已确定,名为wgsl,详见WebGPU Shading Language

在此项目中,我使用WebGPU实现了一个渲染器,其拥有UniformBlockEffectMaterialGeometryTextureRenderTexture等资源的抽象,也有SceneNodeCameraLight的场景管理,还有MeshImageMeshComputeUnit这样负责渲染和计算的单元,基本可以说是麻雀虽小五脏俱全。

除此之外,项目也支持由我之前写的seinjs-unity-toolkit导出的PBR材质的glTF文件,来作为内置的路径追踪渲染器的资源。

路径追踪

光线追踪是不同于光栅化的另一种渲染思路,或者说才是最符合直觉的思路。但由于其性能开销较高,所以很少在实时领域使用,基本都用于离线渲染。但在硬件性能日益变强的今天,实时光线追踪已然成为可能。光线追踪有很多种实现方式,最适合做实时的便是路径追踪:

""

如图,其本质上是从人眼(或者说相机)发射射线,和场景中的物体求交,然后利用“光沿直线传播”的原理反向追踪计算光路,然后再计算直接或间接光照,实际上现在的光栅化流程可以看做是路径追踪中的第一步。路径追踪主要优点是可以实时计算出间接光照,即便是由于性能问题目前往往只能做到一次间接光照计算,但效果也已经很不错了。

但注意,路径追踪并非是万能的,其假设的“光沿直线传播”在遇到透镜时可能会失效。

所以,路径追踪的第一步就是要将整个场景的数据组织起来。我们现在往往将模型视为三角面的集合,并且用“材质”来描写模型的渲染方式。这在光栅化的流程中很自然,但对于路径追踪,由于光线的求交是针对整个场景的,所以必然涉及到整个场景模型的合并,也涉及到如何将我们熟悉的“模型-材质”模式转换成路径追踪适用的模式,这也就涉及到场景的合并,以及合并中材质、贴图等资源的管理。

有了合并过的场景,我们还要有一个方法把整个渲染流程管理起来,路径追踪和传统的光栅化流程不太一样,其前面部分和传统的延迟管线有点类似,但后面却大相径庭,所以如何合理得组织渲染流程也是个问题。

接下来便是光线和三角面如何求交的问题,诚然我们可以直接暴力求交,这对于小场景也不是不可以,但场景稍微大一点便是巨量的性能开销,这就要求我们去寻找更快的求教方法,也就引出一个问题——如何划分场景的层次来加速求交。这里我使用的是现在最常见的BVH,所以这一步就是论述如何构建BVH以及如何在CS中对BVH求交。

取得了光线和物体表面的交点,接下来要解决的便是光照计算。这里做的是真实渲染,走的是PBR流程(这么说可能有点不严谨),而如果要做相对物理正确的渲染,就会涉及到目前大家都在用的BRDF模型。在BRDF的描述中,一个着色点的最终结果是由其法线半球内各个方向的光线的贡献总和,而我们一次只能发射一条射线,并且每次间接光反射也只能反射或透射出一条,这就引出了“蒙特卡洛采样”,也引出了直接光照、间接漫反射、间接高光反射、能量衰减、重要性采样等等。

解决了采样问题,随之而来的是噪声和性能问题,首先是性能问题,诚然我们可以在同一帧发射大量射线来直接收敛出最终结果,但这往往意味着极高的开销,但如果不这样做,就又会导致极大的噪声。而这就引出了非常经典的“时间换空间”思想——将一帧多次拆成一次多帧,然后再来解决降噪。降噪首先要考虑时域的滤波,即“Temporal Filter”,使用一定的权重将当前帧和前一帧加权混合,不断加重上一帧的权重来使得结果最终收敛。以时间滤波为基准,还可以加入空间滤波进行辅助,本项目用到了联合双边滤波。对于静态场景这基本已经足够,但对于动态场景还需要做的更多,比如引入“Motion Vector”之类的,也会面临更多问题。除此之外,由于整个场景是HDR的,还需要进行色调映射防止过曝。

如果只考虑非透明物体,功能方面到此基本可以说结束了,若要考虑毛玻璃这种材质,必须要引入BSDF模型,同时要考虑BTDF和BRDF。除了光照模型之外,透射也会影响到求交的计算,以及终止条件的判定。

然后就是对于WebGPU这么一个比较新的图形API如何调试的问题,尤其是如何调试CS,经过血泪尝试我构建了一套使用Storage Buffer来调试的方法。。。哎。。。

最后是优化部分,其实项目中还是有很多地方可以优化的,首当其冲的就是BVH的构建和求交部分,可以使用莫顿码进行GPU加速。其次WebGPU部分也可以做一些优化,比如Binding Group部分。其他好像。。。想不起来了。。。

总结

我好菜啊感觉还是有很多地方没有理解深刻很容易忘尽力了,但是为了美少女也值得了(虽然发现最后性能好像并搞不定美少女...),但是开坑了就得含泪填完这是我作为码农一面的底线,希望大家多多指正。

高位接盘的小赵

作者 dtysky
2021年9月4日 10:00

本文是南方周末《非虚构写作课程》的作业1。


小赵是一个90后,目前在广州的某个互联网大厂上班。和其他大部分的90后一样,当笔者问到他小时候的梦想时,他有点害羞,露出了不太像这个年龄的表情。

“当然是成为科学家嘛,我们那一代的的小孩,大部分都是这个想法。”像是回忆起了什么,他忽然有点落寞,拿出了一根烟:“可惜早就没有啦,生活嘛,买个房成个家才是正事。”

他并没有抽这根烟,而是闻了闻又塞了回去,咧了咧嘴:“结婚了,和媳妇说好的要戒烟,省点烟钱,而且身体健康也能省更多的钱。”

当被问到理想的转变时,小赵一开始有点抗拒,但很快便调节了自己的情绪:“既然答应了我就说说吧,哎......”他有点无奈:“其实也很简单,我就是个普通的燕雀,哪有资格去想什么鸿鹄之志。”

“可我感觉你也不算普通了”笔者这时候插了一句嘴:“29这个岁数能年入五十万,在大众里也算佼佼者了。”

“看起来是挺多,但扣了税也没多少。”他叹了叹气,继续说了下去:“小地方出来的,努力抠抠索索省了这么多年的钱,好容易凑了个首付,又是一波暴涨。”

“本来看着这个价格就不想买了,但老婆非得觉得再不上车就来不及了,忙慌着计划得三居变两居。”他情绪激动了起来:“房贷拖了五个月下来,利率涨到6,足足多了一个点!所以说大事上听不得女人的话!”

“你后悔了?”

“那可不行,她也跟着我过了不少苦日子。”小赵缓和了下来:“她刚毕业没多久就跟着我了,那时候我刚入行,还是个穷小子。她年轻漂亮,赚的还比我多。那时候我觉得这一切挺好的,再攒攒,大概两三年就能买房了。”

“然后呢?”

“然后房价就像是坐了火箭一样,猛地一下窜上去了,当我们是看得着急呀但也没办法,两边家里都帮不上什么忙,只能眼巴巴看着早生两年买的早的同事开开心心。”他低着头,放低了声音:“那时候是有点酸,但能有什么办法呢,是你你也酸吧......我们压力这么大,就因为晚生了几年。”

“既然如此,为什么不去小城市呢,压力会小一点吧?”

“小城市那里找得到我这行的工作啊,再说小地方也不便宜,房价收入比更高,更没啥盼头。”他又拿出了烟闻了闻,这似乎是他缓解情绪的一种方式:“哎,说白了就是献祭,我们这一代人啊,就是被献祭了。”

“你认为90后是被献祭的一代?”

“那可不是吗?虽然有点后知后觉,但这两年为了房子这事也研究了好些东西,知道了什么叫‘涨价去库存’,也知道了为什么国家要这么做。辛苦涨的工资其实只能抵消部分通胀,说什么劳动最光荣,勤劳致富啊...真的是。”他眼神黯淡:“不过也没办法,毕竟要结婚生娃,总得接盘。”

“可是看数据,很多90后放弃了结婚生孩子,你怎么看?”

“我挺佩服他们的,但我和老婆都比较传统,总觉得得有个后人。而且大家都不生,我们生的话,我的孩子就没这么大压力的吧嘿嘿。”他难得露出了笑容:“反正好歹是上车了,房价肯定还会涨,你看去年底到年初这一波多狠,现在广州的战略是东进,黄埔那边肯定会大力发展,这个价肯定是不亏的,到时候置换个更大的,我们也算是城里人了,这也算是‘共同富裕’吧。”

对小赵的采访到这里就结束了,他也没那么拘谨了,临走前还向笔者介绍了几个他认为很不错的楼盘,这些大多都是在老黄埔和万博附近的“潜力刚需盘”。

今年广州像小赵这样的家庭不在少数。据数据参考,广州去年底到今年二手房价平均上升12.2%,他口中的老黄埔、万博这种地段更是有的小区涨幅40%以上。看到这种涨幅的小赵们出于各种理由纷纷急忙“上了车”,但由于政策调控,导致二手房贷审批放缓,加上频繁加息,所以放下来时往往高出不少。

然而幸运自己“上了车”的小赵没有想到的是,仅仅过了不到一个月,2021年8月31日,广州住建部宣布广州启用二手房指导价并公布了首批名单,而小赵购买的楼盘正好在其中,比起他购入的价格,二手指导价几乎腰斩。加上五年的限售政策,这意味着二手房的流通性几乎被锁死,小赵们的“置换梦想”也似乎变得遥不可及。

小赵更没有想到的是,前不久公布的“共同富裕”消息有了新的进展,传家庭年收入五十万以上的家庭将会被作为税收重点调节对象,而他这样的家庭正好落入这个范围,而在各大社交媒体的舆论中,大部分群众都支持他这种收入的群体被降薪。

2021年是前所未有的一年,个人命运在时代的洪流前瞬息万变,没有人能够预料到下一步会发生什么,过去已然做出的选择也只能承受。自古以来华夏民族便有“兴,百姓苦;亡,百姓苦”的传统,希望在这个时代能够打破这个魔咒,实现中华民族的伟大复兴!

报班学素描和水彩半年成果

作者 dtysky
2021年8月7日 08:00

接近于连续学了半年吧。
太菜了,感觉啥都没学会,尤其是越到后面生活中哦出现了很多意外比较燥。
不过无论如何,算是努力过了,有了些基础,以后在想继续也可以有个底子了。

(这天赋搞啥艺术啊,还是老老实实想着能不能画美少女吧......

以下按照画的日期正序:

"0"

"1"

"2"

"3"

"4"

"5"

"6"

"7"

"8"

"9"

"10"

"11"

"12"

"13"

"14"

"15"

"16"

"17"

"18"

"19"

"20"

"21"

"22"

"23"

"24"

"25"

"26"

"27"

"28"

"29"

"30"

"31"

"32"

"33"

"34"

"35"

"36"

"37"

"38"

"39"

"40"

"41"

毕业五年,离开阿里,来到广州

作者 dtysky
2020年8月3日 08:00

在2020年8月,距离我的27岁生日只有二十来天的时候,我离开了杭州,离开了阿里,来到了广州。这个应该是2018年3月才从上海到了杭州的我没想到的,当时还为了落户杭州(当年需要杭州交一年社保)断了上海的三年社保,现在想想还是有些后悔的,毕竟对于未来的规划而言,可能上海是个更好的选择。两年前我在杭吹的“杭州是互联网从业者的天堂”的呼声中去了杭州,两年后则是因为看清了杭州的“割韭菜之都”的现实而离开。当然,虽然决意不再留在杭州,但如此早就离开阿里却是还是很突然——之前的规划应该是在支付宝干满五年,差不多三十岁的时候再离开。

回顾看来,在支付宝两年还是很开心的,我成功完成了从纯前端到游戏引擎开发的转型,并且达成了热爱技术的程序员都理想的“造出大轮子,并成功运用在亿万量级的项目并成功”的目标。在这两年间,我主要完成了一个内部平台,以及SEIN.JS(可见SEIN.JS - 渐进式Web3D解决方案),一个人设计并开发了顶层Runtime、Unity和Webpack工具链、Inspector、各种扩展、官网和文档,并用其负责开发了2020支付宝五福的3D部分(可见亿级前端项目中的3D技术-支付宝2020年新春活动的背后)以及支持了众多项目。同时还在项目中认识了非常靠谱的小伙伴,比如SEIN渲染层Hilo3d的作者@06wj。

在实现技术理想之外,我也被Leader们给予了充分的精神和物质肯定,在支付宝期间我的试用期、每季度和每年绩效均为3.75,并在入职一年后边晋升P7(完成了26岁前P7的规划),两年授予了三次期权。但无奈后续的一些变化调整过于激烈,经过一些事件、想清楚后还是选择了离职。对我而言这两年除了技术和物质外还有一个很大的收获,就是某些经历让我看透了很多,比如对体验技术部的评价从两年前的迷信憧憬到了现在的客观冷静,再比如Lastday前一天蚂蚁上市让我体验到了一种复杂的情绪(两年前也是刚离职B站就上市了)。但无论如何,年轻的时候遇到这些时候总比三十多了遇到要强,在以后的人生中再面临什么斗争我都应该能后风轻云淡了吧。

离职的时候我在内网发了一篇离职贴《阿里巴巴不再需要年轻人》,引起了热议,之后我也关注了一段时间的事态发展,最终结果还是那样吧我也不再想多说,毕竟已经离开了。在后续那几天,某位合伙人和我的聊天过程中透露的某些信息和集团CEO的发言来看,整体应该还是会向着好的方向发展吧。毕竟无论如何蚂蚁上市后都会有一大批老人财务自由离职让路,当然这和我也没有什么关系了,还是如上面所言,我已经离开了。但从这些天加我的很多同学的聊天来看,这帖子引发热议也是必然的,问题本就长期存在并积压,我只不过是一个导火索而已。如果最后的这个自爆能够间接帮助阿里的年轻一线开发者改善境遇,是再好不过,比如别再每年重复包装开源项目出KPI逼着业务同学用了。如果真的能改善,那“瞬光”这个花名某种意义上也是一种预言吧。当然不得不承认,真诚来讲,这种自我牺牲式英雄式的曝光对一个中二青年也确实是一种满足,不过将加缪视为文学导师的我还是很快脱离了这种标榜,毕竟早年看过的《堕落》这篇小说早已道明了一切。

说到未来的话,没什么意外应该还是定居广州了,大部分基友也都在这,工作方面则是进入微信参与小游戏引擎的开发,定了T10。目前我对于接下来的毕业后的第二个五年、也就是三十二岁之前有一些预期规划,初步规划是在这段时间解决绝大部分现实问题(主要是买房还完房贷、有一些积蓄),之后再去全力追求自己想要的东西吧。过去的我对游戏和文字有着很深的执念,毕业时的目标是三十岁前发布一款独立游戏,四十岁前出版自己的一部经得起考验的严肃文学,而这些对于现在应该是不难达到的,所以也必须有更高的要求。综合近年对自己的剖析和得到的新知识,我认为我最核心的诉求应该只是想要去表达,至于是什么媒介其实不太有所谓,所以比较理想的是五年以后、也就是毕业十年的时候去考央美的实验艺术学院,尽力而为吧。

不过规划虽然看起来都可以稳步实现,却仍然有很需要警惕的东西,而在翻看自己过往作品的时候,发现这些东西早在我刚毕业之际创作的、将自己投射为主角的短篇小说中(寒苍-晗樱-S1-α寒苍-晗樱-S1-β)就已然描绘得非常明白了:

TY成功升到了十七级别。他逐渐远离了那些尊敬着他的同事,认识了更多的十七、十八、十九级。之后是二十、二十一、二十二级。他和这些级们都打下了不错的关系,五年后,他升到了二十级,这个级别的他已经不再需要去做直接和技术相关的了,而且实现了基本的财务自由。这五年中他也换了两三个女友,经历了恋爱的甜蜜和分手的痛苦,也结识了能够谈心的哥们。这些人中有不少是慕名而来的,而他也没有辜负这些人的期望,不断得重复着那个故事。

生活逐渐稳定,不为财务所担忧,这一切都让TY觉得进入这个公司是正确的。他也在一年前成立了自己的家庭,对方是一个温柔的妹子,被他和Sakura的故事所打动,锲而不舍得追求他,最终可喜可贺地修成了正果。他们决定一年后生一个孩子,来满足父母的心愿——他与父母也和解了,他长大了,懂事了,明白了父母的苦处。

一切都很好,很光明。他过上了令人羡慕的生活,也满足于自己的事业。直到那一天。

那天,他在计划和妻子的接下来的旅行,这次他们打算去日本京都——那正好是四月份,赏樱的季节。正在他为选什么镜头而苦恼的时候,妻子忽然拍了下他的背。

“来看看,好可爱。”妻子的手指向笔记本的屏幕。

“什么啊。”他顺着妻子的手望去。“这是......”那上面是一个三分之一的DD娃娃,名字是Sakura。

“......”无视妻子的干扰的、长达五分钟的沉默。

“我出去一趟。”丢下这句话,他夺门而出。

到高铁站买了一张行程五个小时票后,他坐上了高铁。在高铁上用信息告知了自己的状况后,他关闭了手机,呆呆地望着窗外不但倒退的景象,望着它们的植被类型从阔叶变为了针叶。他下了车,并在四点左右到达了目的地。

商场一如既往得繁华,虽然有些角落已经显露出了陈旧感,但整体仍然被人气打磨得非常干净。他已经三年没有来这里了,那或许是和第一个女友分手后,也或许是和第二个女友热恋时——总之,他只能凭借模糊的记忆寻找那个壁橱。

五分钟,十分钟,二十分钟,半个小时......时间不断流逝,他也早已把商场翻了个遍,但却没有丝毫的壁橱的踪迹。他找了个凳子坐了下来,低头保持着沉默。

“TY?”就在这时,有人叫了他的名字。这个声音,他很熟悉。

“你......”在他面前的,就是之前的那个店员。

“对啊,好久不见了。”店员比起当年胖了不少,胸前的标志看起来是升了几级。“听说你混得不错?”

“我有一个问题。”TY无视了对方的寒暄,直奔此次的主题。“Sakura去哪了?”

“我就知道。”店员苦笑。“你来这,也只有这个目的了,虽然我还以为你已经忘了。”

“Sakura会被回收了,因为你最后一次来看她后的两年之内都没有人表现出兴趣,她被当做了废品,回收了。”

“什...么...”TY觉得有些难受。但,似乎没有想象中的那样难受。

他在思绪中寻找着Sakura的记忆,很模糊,在回溯的记忆流中,他的妻子、前女友、朋友、L、二十级、十九级不断得出现,还有他在工作中得到的肯定,在大会上的演讲。

“不过这样也蛮好,你现在看起来挺幸福的。”
“不过,还是有些遗憾。”

TY的确在遗憾,他在回想他为何感到遗憾。

“爸爸,这个叔叔是?”店员的身边跑过来一个小女孩,小女孩看起来五岁左右,有着一双天真的大眼睛。

“这个是戴叔叔,爸爸的老朋友。”店员摸了摸孩子的头,眼中充满了爱意。

“你的孩子?之前没听你说过...”
“那时候我们还没这么熟嘛...虽然现在也差不多。”

“哦。”TY看着小女孩,笑了笑。“你很漂亮呢,要听爸爸的话哦。”

“嗯!”小女孩回应着。“戴叔叔是来做什么的呢?”

“......”
“叔叔给你讲一个故事。”
“好。”

故事讲完了。

“那么,Sakura姐姐,到底长什么样呢?”小女孩听入迷了。“叔叔说的那么神圣,一定有什么特别的地方吧。”

“嗯。”TY顿了顿。
“大概,是那女神一般皎洁的躯体,和华丽、精致的衣服吧。”

总之一句话——莫忘初心。

❌
❌