阅读视图

发现新文章,点击刷新页面。

现实社会不适症

“对于你而言,所谓的农历春节,和公历的新年相比,有什么本质的区别吗?”

夜里,青年H一只手打着手电,一只手揣着兜,漫步在这片无人的沙滩上。提灯的光落在海面上,圈出了一个不规则的椭圆,他低头望去,其中便出现了一团扭曲的倒影。他望着这倒影,问出了上面那个问题,但从他的苦笑就能明白:这个问题,并没有得到任何回答。

这是2023年的最后一天,也是他三十岁后的第一个新年。从黄昏时刻开始,他就一直呆在这里,在这个沙滩之上。为了来到这里,他从广州跨越几个城市,在海边徒步了许久,这是因为他感觉一直有什么东西在呼唤着。他在等待,时而望着近处的灯塔,时而观察度假的路人,时而用无人机航拍,时而焦躁地刷着手机。远处的夕阳缓缓落下,海面的金色逐渐褪去,蓝色逐渐加深,接着,夜幕降临。

""
现实社会不适症

嘈杂的嬉闹,零散的碎语,最后只余海浪不断拍打着沙滩的声音。夜色已深,他摘下了耳机,打开了随身携带的蓝牙音箱,放了首后摇,然后提着一盏灯,散起了步。

对于向倒影提出的质问没有回答这个问题,他并未困惑。毕竟那不再是某个幻像,而是他自己,所以不再有回答,也不再有任何审判。成年人嘛,很多所谓的质问,只不过是换了个方式的陈述罢了。就比如许多所谓的朋友看似真诚想你寻求建议,本质上也不过是把你当情绪垃圾桶而已,从这个角度而言,他已算道德高尚,毕竟他的倾诉对象向来只有自己。

那么少年H和少女H呢,不能让他们出来解答吗?当然不行。毕竟在三十岁生日的时候,他就决定要更加完全承担起作为成年人的责任:要深刻面对现实的操蛋,要继续不屈地勇敢战斗,要逆社会化,要在破万卷书后行万里路,要......

“毕竟,这样的人生更加精彩啊,看看今年做的那些事,多么有生命力啊,让我吸引了更多的朋友!”

然后,他开始总结起了过去一年的经历。这种总结往年都是要在春节做的,但考虑到对于现在的他而言,两个“新年”已无区别,加上在北冰洋旁看极光时也不太可能思考这些,索性就提前做了。

“我在腾讯第一次参与晋升答辩,录制了公开课;假期走了很多地方,拍到了许多好看的照片,摄影技术突飞猛进,还实现了生日微电影;甚至还尝试了原生家庭和解......”

他如此自言自语,语速越来越快,步子迈的快了,身子开始发热,思维也活跃了起来。

「适应」的回想

事业上,他参与了T11晋升答辩,虽然结果是失败的,但整个准备过程挺开心,也得到了不错的摄影素材。作为整个项目的主要负责人,取得专利,去武汉的大学做了宣讲,最后录制公开课上了微信学堂。在下半年,对图形后端的全新改造也比较顺利,用上了Vulkan/Metal/WebGPU,上了XR眼镜设备,一切都在走向正轨。

""

生活上,他对摄影越发轻车熟路,先后购置升级了不少设备。镜头有50.4/16.8/2070/50400,滤镜系统备齐,稳定器搞了RS3MINI,无人机MINI2炸了后购入了MIMI3,不再满足于全景相机画质购入了Pocket3,还有闪光灯等等配件。配合这些设备,他还升级电脑配置到13900+4090,来更好使用熟练掌握了LR、剪映、达芬奇等后期软件,乐此不疲。带着这堆设备和后期技巧,他先后做攻略组团,去了众多景点,拍摄了许多照片和VLOG。

春节,他去了云南。深夜到达丽江辗转入睡后,第二天四点便起床出发去了玉龙雪山。排队,上山,穿梭于云海、长达半个小时的索道中,他经历了人生中第一次因大风导致的暂停。在大风中摇摆的缆车,收到的停运短信,有些不安的同伴,他观察着这些,没有表露出丝毫恐惧,只是用随身音箱放起了一首BGM,在音乐声中不断按下快门。此刻他终于确认了:自己所期待的从不是呆在那个狭小的房间内,日复一日重复着那些被前人嚼烂的所谓真理,而是身体力行去经历一段段精彩的戏剧。

得益于在青海的生活经历,在登上4680米的过程中,他并未有除了饥饿之外的任何不适,甚至于在兴奋感的驱使下,他还脱下了租来的羽绒服,在山顶架起三脚架念了首以前写的诗。在之后的行程中,他也仍然保持着这兴奋。蓝月谷和金沙江湛蓝的水面,深夜泸沽湖畔的星空,夕阳下的金色的女神湾,与黄昏天空连成一片的草海,还有清晨湖心游船时,远处的耶稣光。

""

这趟行程对他而言,有太多第一次。第一次自驾,第一次上雪山,第一次拍到星空,第一次和路人做了访谈,第一次清晨游湖等等等等。作为新年的第一次旅行,他非常满意,尤其是队友都很有趣靠谱,他也认为之后的行程会一直如此。

清明,在经历了一场狗血后,他明白了真诚原来是可以伪装的,于是约上了三两好友去所谓的“疗伤”。这趟短期旅程,他选择了江西。他们先去了庐山,在如琴湖旁悠闲地谈心,在锦绣谷和非常有礼貌的猕猴互动。次日虽然下起了大雨,但雨后起雾的五老峰却别有一种“只缘身在此山中”的氛围,尤其是到了三峰后,大风将对面山头的大雾吹散,也让他终于理解了什么叫做“日照香炉生紫烟”。下山后,牯岭镇完全被大雾笼罩,像是寂静岭的里世界一般静谧,而那些弥漫在雾中扩散的绚丽灯光,又让他们仿佛处于赛博朋克的世界中。最后的三叠泉瀑布虽然一般,但整个上下台阶徒步的过程也是一种挑战。

""

庐山之后,他们自驾去了婺源。当日老天赏脸,晴空万里无云,运气好也没有太堵车。在这里,他拍到了古色古香的山间小镇,大片的梯田花海,当然也作为工具人为同伴拍了不少游客照。最后虽然没有赶上月亮湾的日落,但也拍到了非常满意的景色,给行程画上了完美的句号。

""

散心之后,他决定在三十岁生日前再努力一次,五一报了某平台的交友旅游团,去了漳州和东山岛。现在的他已经可以比较好地克服社恐,进行必要的社交。他首次尝试了场记摄影,拍摄了许多活动照片,并被官方大量引用。漳州古城商业街虽然也是流水线出品,但街拍氛围不错,在夕阳下的街边,他让本次的舍友做模特,得到了第一张满意的男性人像。夜里的围炉煮茶碰巧遇到了当地人的请神活动,好不热闹。后面的云水谣景区虽然天气状况不佳,但途中山间的浓雾、特色的土楼、河边的水车、石阶上朋友的倒影,都给他带来了不少灵感。

""

五一行程的高潮是在最后的东山岛,虽然堵了挺久车,但到达海边的那一刻,他感觉一切都是值得的。夕阳下的沙滩连着海面,都被撒上了一片金黄,几位同行的团友作为模特,让他抓拍到了本年度最喜欢的照片之一。入夜后,海边的赛博篝火晚会非常生草,却也确实释放了快乐,他用镜头不断抓取其中的瞬间,事后的手电光绘更让一般三十出头的大老爷们体会到了青春的感觉。

""

近处的风光看的差不多了,他将目光投向了远方。端午凑够八天假期,他组了个团,直奔新疆伊犁。本次行程多有坎坷,导游不太专业导致景区门票、住宿都出现了不小问题,最后几乎全员的急性肠胃炎和他本人的高烧更是折腾得苦不堪言。不过好在司机非常靠谱,除了那拉提景区外全无耽搁。

他们从乌鲁木齐出发,沿着国道开到了赛里木湖。此时正直伊犁最美的季节,公路沿线风景美不胜收。赛里木湖的广阔虽比不上大海,却也别有一番风味,之后的果子沟大桥和黄昏的薰衣草花田奠定了本次辽阔风光的基调。在恰西森林公园,他看到了草原、森林、溪谷、雪山一体的精致;在喀拉峻,东边的五花草甸那一望无际的辽阔,西边连绵起伏的丘陵和奔驰与其间牧民的逍遥;在特克斯人民医院挂水时和大爷大妈的人生相谈,伊犁河谷沿线高烧近40度的昏睡,那拉提的酒店,巴音布鲁克深夜的蒙古大夫强效输液;还有最后蜿蜒的独库公路,从草地到溪谷到雪山,一天看完四季变迁,也感受到了修路英雄的伟大。

""

毫无疑问,这次旅程是充满戏剧性的,有精彩的开头,压抑的发展,扬升的结尾,演员也全身而退回归了生活。他由衷认为其实挺好的,但希望下次不要再出现这样的戏剧性了,毕竟年纪也不小了禁不起太多折腾。但让他没想到的是,这一年还有一个更有戏剧张力的场景在等着他。

回家修养了一段时间后,时间来到了八月末,也是他生日的时点。在数次查询天气预报,判定银河出现的可能后,他最终放弃了武功山,选择去衡山度过这个三十岁的生日。他背着十公斤的设备,徒步登山了衡山山顶,计划拍下日落、银河、日出三个延时作为新生的纪念。但这次天公并不作美,天气预报并未准确。当他到了祝融峰,却发现落日被厚厚的云层遮住,只留下晚霞;夜里到了会仙桥,却被告知有人跳崖,折返到了一个平台拍摄想要银河,又发现银心方向光污染严重,只得凑合;一大早赶到观日台,日出是看到了,却因为经验不足没带长焦。这个三十岁,正如他过去的人生,充满不如意,却又总能勉强完成。

""

生日之后,仅仅过了一个月,在国庆,今年他的最后一个行程到到来了:318川藏线摄影之旅。吸取以往教训的他,专门找了三个有着摄影爱好的同伴,包了个车,以成都为起点,驶向拉萨。第一天傍晚,他们太阳落山前赶到了鱼子西,在那看到了贡嘎背面日照金山,全员都很欣喜得认为这给整个行程开了个好头。

第二天,在经历了两三小时搓板路,以及一个多小时的骑马上山后,他们到达了冷嘎措这个贡嘎雪山最佳机位。虽然日照金山并未成功,但在无风时,冷嘎措中倒影与贡嘎雪山本体完全对称,蔚为壮观。一段日落延时后天色已晚,全员只得跟着当地人徒步一个半小时下了山,入睡后又经历了深夜140的心率惊醒。第三天路过世界高城理塘,他们在某个“林卡”(藏语的花园)入住,第四天直接进了亚丁。稻城亚丁的行程稍微有些预计失误,到到洛绒牛场时马已被租完,只得徒步上山。已然相对适应了高反的一行人,坚持从洛绒牛场爬到了五色海,全程海拔4200到4700,往返十公里。虽然由于天气等原因出片并不多,但这个负重徒步的过程本身就意味着一种挑战的成功。

""

第五天沿着岔路,他们进入了格聂,虽然定错了酒店并未到预计的丁真故乡然日卡村,不过在格聂镇扫街还是有不错收获,夜里也拍到了此次行程唯一的银河延时。第六日清晨四点半,只睡了四个小时的众人顶着寒意出发,只为赶上计划中的第二次日照金山——格聂雪山。此时并非旺季,所以从五点半他们到了格聂之眼开始,直到拍摄结束也没几个人。找机位,架三脚架,调参数试拍,随着太阳升起,天边破晓,金光从山脉和草原上不断扫过,令人心旷神怡。虽然由于云层太厚日照金山并未成功,但风景本身的宏伟已经完全值得这样的劳顿。结束拍摄后,接下来的格聂南线更是美不胜收:汹涌的溪流在山谷中流淌,一条原始的土路在静谧的森林中穿梭,无数倒下的大树安静得躺在两侧,除了偶有的本地人和摩友骑着摩托路过,罕见人迹;越过了森林后,是宽广辽阔的雪山草地,以及海拔落差上千米的环山土路,最终在县城吃了顿豆花鱼消去饥饿与疲惫后,他们进入了西藏,并在被堵在路上时左右来了张长曝光。

""

第七日是中秋当天,沿怒江沿线驶过七十二拐,最终到了然乌湖。这个季节的然乌湖并不养眼,但第一顿藏餐店主的祝福,以及夜里的满月延时,让他觉得倒也不错。第八日的米堆冰川,则是让他最为惊喜的:正是此处最美的窗口期。肥沃的土地孕育了大量的杉树,这些杉树的树叶全部被染上了洪荒两色,散落在地上的程度也恰到好处。他们骑着马沿马道上山,又沿步道徒步而下,贪婪地拍摄着这红叶、雪山、玛尼石堆、农场木屋交织而成的世外桃源。

""

离开米堆冰川后他们直奔鲁朗,初步领略了林芝的美丽后沿林芝国道前进,一路的水面、树木、天空无愧于“小江南”的称呼,甚至比江南更为漂亮,最后,他们到了索松村,并计划在这拍摄南迦巴瓦日照金山。这个季节的雅鲁藏布江水浑浊,所以网传的倒影最佳机位并不好看,最终他们选择了回到酒店花海前景等待。虽然据老板说前三天都能看到今天概率也很大,并且一开始确实看到了山头冒出,但希望越大失望越大,这最后一次的机会还是失败了。感受了下村民游客的夜生活,清晨拍到了半截云上去的雪山后,他们驶上了318的最后一段高速,前往拉萨。高速两侧是非常美丽的秋景,金色的阳光洒在山坡上,金色的草地点缀着蓝白色的小屋子,金色的稻田中人们辛勤地劳作。拍摄了众多美景后,他们终于到了拉萨。

""

拉萨某个饭馆的布达拉宫,夜里的街拍,都昭示着行程的即将结束。次日,他们出发去了最后一个景点,羊湖。羊湖很美,如宝石一般湛蓝,这不需要过多的语言。然而在去羊湖之心的路上,随着轰的一声,大脑空白,烂摊子接踵而至...

""

“行了,整个积极向上的总结,别插进来扫兴。”他说了这么一句话,打断了318的回想,随后继续——

在旅游之外,他还尝试了许多新的摄影品类,人文扫街,夜景人像,公园微距打鸟拍蝴蝶,活动场记,城市风光等等。虽然观众寥寥,但自己作为纪念也颇为有趣。除此之外,他还通过像是广州大剧院戏剧创作工作坊之类的活动,认识了不少兴趣相关的朋友。

""

""

在不断输入、磨练技术后,他这一年最重要的作品诞生了,为了三十岁生日这样一个特殊的时刻,他给自己拍了部微电影。这是一部非常私人的私电影,情节比较意识流,后期参考了塔可夫斯基的风格,虽然受限于成本能力,但成果也算是满意。

""

这部生日微电影,对他而言正好也是理想的一部分。除了微电影,他还尝试了AI炼丹画图,成功练出了LORA,画了立绘。后面还用其他方式开始了独立游戏的Demo制作,并实现了第一章的最小版本。

""

并且这一切之外,有一点最为反差的行为,就是他竟然尝试着和原生家庭和解了。和几年未见的父母见面,拍照,打印照片送出,发朋友圈,都象征着某种在“理解”之上的部分“接受”。他不要求对方补偿给自己什么,只是希望他们能好好过个晚年。

所以,此时的他大可以骄傲得抬起头,对着EF的“夕”、四叠半的“我”、异域镇魂曲的“无名氏”、樱之诗的“直哉”、C†C的“太一”等等角色,对着加缪、布尔加科夫、wowaka、黑柿子等等创作者说:

“看,我终于走出自己那狭小的房间,迈向更加广阔的世界了。即便是那个如此脆弱的我,也能做到直面这个曾让我无所适从的世界!”

他说了这样的一句话,或者说是抒发了某种感慨?但浮夸的言辞显然不符合他的人格,这让他的内在和外在开始错位,随后就如往常一样,他再次低下头看着海面,对自己戏谑地嗤笑了起来。

这个笑从锐利的眼神中透出,击穿了那积极阳光的人形牢笼,冲破了那些看似精彩的幻想。

无言,沉默,悲叹,愤怒,狂笑。

随着狂风袭来,那为戏剧性支付了种种代价的灵魂开始躁动,它一边质问,一边嘶吼。

「不适」的嘶吼

“为什么?!”这是青年H过去三十年说的最多、想的也最多的一句话。这一句简短、精确、充满力量的质问贯穿了他的一生。那么在这一年,他又到底是在向什么,发出了怎样的质问?

是质问那个女同事?质问对方懂不懂自己TM在做什么。就因为在一个内网别人都骂她的没逼数的提问下,以助人为目的客观真诚给出了回答,就针对自己积怨挖坟断章取义特殊时期的言论召唤铁拳攻击同事,并且拉公司下水,这就是某些新一代高材生的思维逻辑?是不是脑子有毛病?

是质问他的父母?质问为什么每次给他带来的都是失望,都是一次次的让他明白这所谓的“和解”只是谎言?为什么好不容易发出和解的信号,换来的不是对方的“理解后好好过日子”,而是觉得儿子“除了当个对外炫耀的摆件,还可以作为兜底的工具了”来索取?

是质问那些所谓精英?质问他们为什么获得了那么多的权力和资源,却只知道尸位素餐,占着茅坑不拉屎?为什么不用自己的能力回馈社会而是在庙堂之上空吃资源,整天自以为是俯视众生,瞎几把指挥这个那个东锤西锤,一边享乐一边剥削打压,就是不做人事?

是质问那些乐子看客?质问他们为啥整天就知道把节奏带来带去造谣传谣,像蛆一样恶意中伤那些无辜的人?天天闻着味盯着点细枝末节打倒这个那个,对别人搞得是存天理灭人欲那套,结果对自己宽容无比肮脏龌龊,双标狗TMD是哪来的脸去做键盘圣人的?

还是质问那些他曾施以援手却倒打一耙的......

“唉,吼那么大声做什么。”

他停了下来,停止了这些无聊而碎片化的发泄,对于已经发生了的事,这没有任何的意义。为了不影响正事,他早就演化出了一套节能系统,所以外界刺激总能被很快抛诸脑后,这所谓的发泄也不过是某种节目效果。在他看来,有意义的质问对象永远有且只有一个。

对着自己的倒影,他发出了最后一句质问:“就因为我尽可能想去做一个热心正直的好人,所以就应该被枪指着吗?”

发出这句质问的时候,他应当是带着激烈的情绪,带着不甘和愤恨,带着对自己无能为力的屈辱,但是他没有。他只是再次抬起了头,望着远处黝黑的海面,感受着冰冷的海风吹过自己的脸和头发,发了会呆。当然,对于他而言并不存在的发呆,他又开始回想,接着方才被打断的那一刻开始回想:

在那场车祸,他尽力处理好了一切的麻烦。同伴的伤势,和旅行社的扯皮,和司机与交警的沟通,前后他一人处理了大多数,甚至还因为过劳倒在了酒店的房间中,感受了一次濒死体验的走马灯。最终他让旅行社退了全款,自己基本一分没拿,全部补偿给了团友;他也没有让司机负全责,而是正常报了车险挽回了一些损失,毕竟从他看来,这次车祸他们自己确实也有不小责任,不能完全推卸。但即便是这样的处理,还是有人不满,不满在他所谓的“烂好人和一事无成的温柔”。

听到这个评价时,他第一感受并不是愤怒,而是可笑。自己分明已经在坚守道德底线的状况下,最大化挽回了损失,却还是要被人指责,这是哪里来的道理?毕竟就他对其他事件的了解,自己已经比绝大部分人靠谱多了。

顺着这个思路,他又回想起了今年遇到的种种不好的事件,这些事件都让他深刻感受到了生命的脆弱,承担责任的重量和压力,以及面对许多状况的无奈,他甚至还做好过被开除的心理准备。想着这些,除了可笑,他忽然觉得有些可悲,可悲在他对这个世界的看法的转变。

当他认为可以信任别人,别人却往往背信弃义,所以他不再轻易信任。
当他选择尝试依靠别人,别人却总甩下烂摊子,然后他选择只靠自己。
当他想要劝谏帮助别人,别人却常常倒打一耙,于是他开始尊重命运。
他总是在期望这个世界变得更好,这个世界总向着更烂的深渊滑去。

他曾经数次怀疑到底是不是自己的问题,所以尽力克服了许多缺点,尽可能成为了比以往还要靠谱的人,并主动承担了诸多责任,但为何却比之前更惨,更加无所适从?为什么那些甩手的人,却总是一边篡夺别人上十字架,享受着人血馒头换来的权益,一方面又躲在安全区装出愤怒的样子,只知道键盘叨叨着什么“背叛”、“高尚”、“正义”?为什么某些受到压迫的人,不去追求公平正义,反而去崇拜那些吃人的“精英”,去共情那些压迫自己的野兽,去攻击自己的同类同胞?为什么那些沉默的帮凶在你试图努力说些什么的时候,还会看你不顺眼,要用尽手段让你和他们一样沉默?

他感到非常疲惫,因为从本质上他就不是一个热心的人,甚至由于读书和经历多了还有些凉薄。只不过为了践行“混乱善良”的信念,他才尽可能让自己去做到热心尽责。所以这些破事经历多了后,在某些时刻,他会得出一个结论——“TM的都是贱”。这个世界上有太多的傻逼,根本不需要也不能被拯救。要让世界变好根本不能靠什么“人性本善”的谎言,而是把让世界变坏的人都突突掉就好了。人类少一半,幸福感增加一倍,“精英”少一半,幸福指数将会飞跃。

但每当这时,却总是有一些外力打断他的愤怒,让他脑海中出现另外一些的想法,让他动摇。比如此刻,他正攥紧拳头眉头紧锁盯着远方陷入沉思,突然海浪随着大风涌来,一个浪头扬起的水花有少许溅到了他的脸上,这刺骨的冰冷让他哆嗦了一下,待回过神,他叹了口气,又想起了新疆高烧吊水时,一旁搭话聊起来的那位大爷,以及他最后说的那句话:

“小伙子,我们年轻时和你一样,但后来经历了许多,现在已经没有人可以骗得了我们了。你很聪明,也很正直,但千万不要让这份正直害了你,从一个极端走向另一个极端,毕竟——”

“通往地狱的道路往往是善意所铺就的,如果它最终导致了你自己或者他人的毁灭,不太值当。”

「现实社会不适症」

青年H感到不适,对一切恶心残酷肮脏自私浮夸粉饰腐朽自欺欺人自以为是感到不适,这种不适甚至已经可以称得上是一种病症,他将其命名为——

「现实社会不适症」

不适症?对于他而言,这个社会难道不是一直都很不适吗?正因为不适,所以才会有去改变的冲动,怎么现在才得病?其实不尽然。事实上,当你漠视现实不想适应的时候,病症不会存在;只有在你为了某些欲求试图去适应之时,病症开始显露;倘若在这一切之后你再次想漠视他,那么大概率是会得上这个病了。

这个病的核心,在于“发现”之后,“承认”与“不愿”之间的矛盾。这是一种在象牙塔中被理想主义所荼毒,又为了资源尽可能社会化,最后在逆社会化过程中才会出现的疾病。

在象牙塔中,他认为正直善良是一种普世价值,是天赐一般的铁律,是每个人的天性。只要顺从天性,那所有人都会幸福,而背离天性的人必将毁灭。

后来社会化,他明白了正直善良只是一种道德判断,而道德判断是依附于时代的。这世上并不存在上帝,也不存在末日审判,没有彼岸,没有天堂,也没有地狱。但即便如此,他仍然想要去将其作为一种信念,并尝试说服他人也拥有这个信念。

最后,他发现不正直善良的人往往不但不会被惩罚,反而越是背离,就越是获益。他向来不太所谓坚持信念对自己的损害,但当他发现这种坚持不但会伤害到自己,还会伤害到自己身边的人,甚至还可能会助长那些恶人的威风和利益时,他动摇了。

“这个世界充满了狗屎,需要强力的净化!”最终,他得出了这样的结论。

这是愤怒吗?大概是的。但正如所有强烈的情感都无法持久一般,他也无法始终保持这种愤怒。对于他这样的一个行动家而言,倘若某个计划无法执行,那它依据的情绪和能量也将很快被瓦解并转移。他也尝试过强迫自己愤怒,但这种愤怒,又有多大的意义呢?毕竟从理性上他也明白:如果一个社会所有的人都是圣人,那么这个社会只会毁灭得更加惨烈。所以渐渐得,他不再过多关注那些无法改变的东西,而重点在自己能做什么。

但说到底,他想做的那些项目,本质上也寄托着改变社会的想法。做成事对于无法依靠任何人的他来说,本身就需要高度的社会化来获取资源,所以他必须要承认社会的规则;但他做的那些事,又是高度逆社会化的,是不愿承认这些个社会规则的。这种矛盾让他的进展大打折扣,焦虑随之而来,一种长期的、远期的、无法根治的焦虑。

为了解决这种焦虑,除了继续龟速推进项目外,他尝试了许多,许多的...重复。尝试找同伴,尝试拍摄不同题材,尝试不断旅行,尝试学习新技术。在外人看来,似乎每天都是精彩的生活,但只有他自己知道:同样的对话,同样的开始,同样的结束,同样的精彩,也是同样的逃避。他在逃避真正重要却艰难的事情,而最可怕的是就连这些“逃避”,也逐渐失去了效果。因为对于他而言,重复本身,就是一种折磨。重复越多,就有越多的非平凡成为平凡,而平凡,对他来说是一种毒药。

过去的他总是觉得很痛苦,但这些痛苦某种意义上也是缓解焦虑的良药。而现在呢?就连痛苦也仿佛成了遥不可及的存在。痛苦本身并不是最可怕的,真正可怕的是大量痛苦后的漠然。漠然,会唤醒他虚无主义的内核,然后是空虚、无聊、无价值、无意义,如果再继续下去,死亡便接踵而至。

所以现在他会对别人说:“什么人类观察,什么洞悉命运,都只是自以为是的傲慢罢了,所以我不屑于再去做这种事了”。但只有他自己知道真正的原因,那就是“没啥意思”。

在经历了足够多荒诞之后,连戏剧性本身也失去了张力。一眼就能看穿的人性算计,不用动脑就能察觉的粗糙谎言,无需多想就能预测的悲惨命运,越来越多,越来越明显。而且就算预测了又能如何呢?你去劝别人,费心费力不说,还会惹得一身骚。他接受了人各有命,生死由天,不再轻易被道德绑架,口头禅也变成了“事已至此,那就这样吧”、“还能咋滴,过一天是一天呗”、“那你说咋办,要不你上”、“你想装睡,我也没有办法”。反正像是那些所谓事业婚恋等等个体悲剧,本质上也是一种宿命。毕竟一个成年人如果无法为自己的选择支付代价,只知道贪婪索取祈愿掉馅饼,那也意味着他没有选择,没有选择,那么悲剧便是一种必然,这就是他的宿命。

最为残酷的是,这种宿命,也落在了他自己身上。

随着岁数的增长,到了这个年纪,社会化的用力过猛带来的最大副作用之一,就是那对于现实身份认同的需求。这种认同并非源自内在,而是外在,所以他时不时会被现实的评价左右。现实的评价,在他的体系中,相对于内在的向上的生命力,是一种打击性的压迫。在这种压迫感下被动的行为,与内在理想的矛盾冲突所产生的不适,便是这个「不适症」在当下的核心:

为了避免可以预测的麻烦,交友时也会考虑对方的经济能力,不再只看精神,这算是一种算计吗?
为了规避浪费心力的冲突,对大多可以较真的状况装傻糊弄,甚至置之不理,这算是一种谎言吗?
为了掌控无常多变的命运,放弃更多可能的冒险机遇,安于现在工作的状况,这算是一种怯懦吗?
这样的我,算是背离了所持的信念吗?如果不是,那么又为何会如此不适呢?

他不知道,不,应该说他知道,但却没有办法。为了不在完成必须实现的事情前过早毁灭,避免情绪激化导致各种越来越年轻化的绝症,他开始有意控制自己的情绪安定,随着而来的则是兴趣的失去。他不再读那些严肃文学和哲学,不再痴迷于那些锐利的当代艺术,不再持有极端的立场光谱,但看到油腻的中庸之道更觉恶心。他不再那么想出国工作,也对留什么学读什么艺术失去了执念,因为现在的他知道到处都一样烂,文学艺术也不是什么社会和自我的解药,而且那些个圈子还可能更加恶心。

没错,他还是想完成自己的作品,描绘时代悲剧的独立游戏,寻找自己从何而来的纪录片,承载整个人生的VR作品,对,他还想出去走走。他想走遍这个世界,去高原,去雪山,去沙漠,去溪谷,去繁华都市,去文明遗迹,去北极冻土,去南极大陆,去非洲草原,去......他想去这些地方,留下照片影像,留下自己存在于那些地方的记忆,留下自己存在于这个世界上的痕迹,但,但是...对于“意义”这个问题,他仿佛没有那么坚定了。

“就算都完成了,又能怎样呢?”面对这个问题,他只得沉默,只余一片空白的大脑。在这稍许的寂静之后,前方灯塔的塔顶慢慢闪起了光,那忽明忽暗的信号就像是某种呢喃的低语:“回来吧,回来吧,回到你真正的家乡......”

他应着这召唤,继续向前走去,即便海面渐渐没过了双脚。一步...两步...三步...灯塔越来越近,水位也越来越深,寒意从下半身蔓延到了他的躯干,但他的感官已然完全被那低语紧紧抓住,只是一步一步向前走着,走着,直到——

刹那间,风云变幻,一道纯白的光在天边炸开,短暂的轰鸣之后,灯塔陷入沉默,在其之上,一轮皎洁的明月升起。

“月...亮?”部分感官恢复的他,察觉到了异常。在他的前方,确实有一个月亮悬挂在夜空之上,但海面却没有它的倒影。他猛得转身一看,却发现了另一个在云层中若隐若现的月亮,相比之下,海上的那一个显得有些过于完美了。

“所以,您是恐惧着在未来某一天,自己会因为现实压迫的疲惫,因为感受不到意义,而放弃那些曾经珍视的存在嘛?”

在他循着声音转回来后,那完美的月亮已然不在,只有一位从灯塔中向他走来的少女。正如之前一样,少女H又出现在了他的面前。他看着她,有些羞愧地低下了头,说:

“我...最终还是处理不来这许多的事情。总是有麻烦不断找上我,总是弄巧成拙,所有的事情又总是向着最坏的状况发展。我已无力负担别人的命运,精力透支,身体不适,为了活下去,我必须要冷漠一些,要屏蔽那些痛苦的事情,要保护好自己的资源,要选择放弃一些东西,但好像我放弃的越来越多的,就是那些......”

少女H听到这些话,并没有给出直接的答复。她只是走到他的面前,握住了他冰冷的双手,让他抬起头,随后盯着他的眼睛微笑着轻声说:

“其实,不去在乎什么现实身份认同,做好随时失去一切的准备,一直当一个「文艺青年」,也挺好的不是?至少比起那些真正不幸的人,您还带有期望得活着呢。还记得嘛——”

说完,少女松开了手,走到了他的身后,随后捂住了他的双眼。黑暗,干净的黑暗,纯粹的黑暗,温柔的黑暗,什么都不需要去看,什么都不需要去想,什么也不需要去操心。

“让我们来回忆一下吧?三......二...一!”随着少女的消失,他从短暂的黑暗中恢复了光明。身后的月亮跃出云层,照亮了沙滩和海面,他的双眼就像是被清洗过一般,变得格外通透。

「你在祈求着什么?又在期望着什么?」
「那挣扎的内心,不比谁都更加切实地活在当下吗?」

在短暂的寂静后,音箱中的歌声伴随着海浪,再次传入了他的耳中。

何为「文艺青年」

“文艺青年”这个词,对于现在的青年H来说,显得有些陌生。他已经记不清上一次给自己加上这个称呼是多久以前了,随着大众的语境变化,这个词的含义逐渐被扭转后,他便对这个称呼开始避之不及。

虽说是避之不及,但也只是表面上罢了,在他的内心中,对这个称呼的态度一直都是矛盾和暧昧的。在过去很多年中,他一直以这个称呼自豪,什么哲学文学艺术都想沾点边,言必称加缪尼采卡夫卡,托翁福克纳卡尔维诺伍尔夫;在后来他开始对电影感兴趣,什么豆瓣TOP100,什么文艺纪录片,看完还要截个图发个影评。

从这些角度来看,他和那些不想生产只图享受,整天咬文嚼字叽叽歪歪阴阳怪气酸来酸去的人好像也没什么区别,而这些人,也是大众眼中以“文艺青年”标榜自己最多的那批人。所以由于逐渐成熟而从内核厌恶那一类人的他,便自然而然排斥起了这个称谓。不过这个排斥真的是自然的吗?他并没有去细想过这个问题,因为它不重要。但方才少女H的那些话语,却让他重新考虑起来这个问题的重要性,然后,他决定仔细想想。

他讨厌那些只图享受的精神小资,所以就讨厌起了“文艺青年”这个称呼,从这个角度来讲,精神小资和文青似乎是绑定的,那么过去自称为文青的他,是否也是只图享受呢?如果是,他应该非常讨厌过去的自己,但现实恰恰相反,他并不讨厌过去的自己,反而觉得那个少年还挺可爱的。

不错,他想了起来,那个少年不但不图享乐,还没日没夜卷的飞起,甚至到用痛苦来激发灵感的程度。这样的一个少年会自称文青,那么如果不是记忆出错,就一定是自己和大众的定义出现了偏差。那么区别在哪呢?是除了文学艺术电影之外,他还受到了二次元或者说是那些第九艺术的影响,受到了开源社区的影响的原因?

过去的他可能会就这样判定,但现在的他却理解得更加深刻,其实根本没有那么多复杂的原因,真正的原因非常简单,也就是他一贯的内核:

得到了,就要做出回报;为了得到要付出代价,不能不劳而获;要为选择承担责任,不能推脱给别人。

而这,就是他对这个现实社会的诸多人、诸多事、诸多命题厌恶和不适的核心原因,因为它们并不遵从自己那朴素的等价交换法则。他不但难以忍受那些不劳而获没有逼数的傻逼,也难以接受那些努力生活却没有被回报以许诺的幸福的人们。

正是这种朴素的“等价交换原则”内核,让他觉得既然得到了享受,就必须尽可能给出相应的回报:享受了文学作品,就必须去练习表达写出散文小说;享受了电影照片,就必须学习摄影后期分享创作;享受了动画游戏,就必须尽力做个游戏去带给阿宅人文关怀;享受了开源分享,就必须将技能转化为开源项目进行回馈......

最终他发现,他并不是将审美作为一种手段,一种逃避现实寻求幻象和宽慰的手段,而是以审美为导向,来尽力通过创造,回报给那些给过自己享受的作品。换一种说法,他去欣赏那些作品,从来都不是为了享受,而是为了获得在直面残酷真相的情况下,还足以勇敢战斗下去的勇气,还足以坚持创造的决心。他忽然想起了在整理日记本时,贴在封面上的那句话,那句二十年前的自己留下的稚嫩文字:

“即使充满了痛苦与灰尘,也请睁开你的眼睛。”

这句话仿佛成为了他接下来人生的注脚,无论面对怎样的痛苦,怎样荒诞混乱的天灾人祸,即便是心中有极大的不适,他也从未退缩过,而是硬着头皮睁着双眼,注视着世界,注视着自己。

“面对残酷的现实,仍然坚持活下去,并尽可能完成所有想完成的事,这才是真正的文艺青年。”

他决定不再用大众扭曲的语境去看待这个称谓,而是用了自己很久以前就下的定义。他摇着头淡然笑了笑,对着眼前沉默的灯塔继续说道:

“拥有这种内核,却又选择走这样的一条路,「不适」也是理所应当。”

“但也正是因为这种不适,所以才真实,将这种真实记录下来,便是真诚。”

“如此的存在方式,虽常有万箭穿心的痛苦,但也有不枉此生的快乐。”

“所以,我仍然要选择去做一个文艺青年。”

“过去是,现在是,未来也是。”

三十岁后的第一个新年,面对近在咫尺的灯塔,青年H最终停下了脚步。

他抬起已然被海水没过的双腿,转过身,回到了来时的方向。

夜空中那轮并不完美的月亮,洒下了微光,在前方探出一条银白的小路。

他深深吸了一口气,迈着坦然的步伐,沿着这条小路,向令他不适的现实归去。

归去,归去,继续和时间抗争,去向人生的下半场。

“请您拼命向前走,至少在那之前,不要再回头!”

在这归途中,传来了少女H的最后一句呐喊。

""

三十岁生日,我给自己拍了一部微电影

""
三十岁生日,我给自己拍了一部微电影

和二十八岁生日那作为一个老二次元分界线的「Double;14」不同,三十岁生日对我而言,更多是社会意义的一个分界线。

二十九岁这一年,我改变了一些生活方式,学会了如何去好好生活。我对自己好了一些,降低了工作预期,和家里相对和解,然后终于来到了三十岁。

回首过去,我的人生应该分为三个阶段:懵懂无知的阶段,受到二次元/文学影响的理想主义阶段,然后是以进入阿里开始的社会化阶段。

在第三个阶段,我的重点开始从第二阶段的“如何完成属于自己的游戏”,倾斜到了“晋升P7”、“年入XX”、“跳槽”、“买房”等等问题。在内源性焦虑的驱使下,我确实完成了大部分的计划,虽然也算精彩,但却越来越迷惘,越来越找不到未来的方向。

直到近期完成这个庆生作品时,我才得以完全用一个旁观的视角看待过去几年的自己。这时我看到的不是那些世俗成果带来的所谓快乐,而是一个理想主义者在社会化过程中改变初心的悲哀。

我的迷惘矛盾和不快乐,就是从比重越来越高的世俗问题开始的,与此同时的还有对他人目光的越来越在意。虽然通过精力的大幅倾斜,我解决了很多世俗问题,但在这个过程中我也逐渐淡忘了初心:我只是想通过一些作品,来填充我那虚无主义的内核,而事实证明世俗的成果是做不到这一点的。

似乎是从某一刻起,我意识到自己已然厌倦了这种由级别/收入/地位定义的评价体系,而是更关注具体的人做过哪些具体的事。因此,职场上的成就也越来越难以让我快乐,反而是去年在业余偶然开始的摄影等(这个片子也是我用自己的设备拍的,后期调色剪辑配音也全是自己来),让我找回了那么一些快乐。

真的可以让我快乐的,应该是那种没有任何利益计算,并非是为了所有人的目光证明什么,而是那种用爱发的纯粹热爱,仅仅是想要把它们做出来的那种无用而耗散的激情。就像是大学时走在上下课路上,光是构思起剧本里的情节,想到会有人因为感同身受得到关怀,就会浑身震颤的那种最原始的快乐;亦或是在努力做开源项目写技术文章时,想到能够帮助和我曾经一样的、愿意努力学习的新手开发者,并感染他们也无偿贡献出自己的力量时的那种喜悦。

而我一开始决定投入精力解决世俗问题的动机,大概也是为了更好得服务于这初心吧,但最终我也并未通过这样的方式达成“三十岁前完成独立游戏”的初衷。

人总是会在绕路中堕落,好在我总会定时回顾自己的道路。所以近一年我在酝酿并尝试着一个逆社会化的过程,去让自己慢慢回到大学的那个起点。但当然,人生不能重来,经历和记忆无法抹去,作为普通人的我为了实现目标,绕路也无法避免,不过至少我还有着选择的能力。

在三十岁的这一刻——

我仍然没有搞懂第一问:
「我从何处而来?」

也没能探明第二问:
「我到底是谁?」

但至少希望可以寻求到第三问的答案:
「我应当去向何方?」

回顾2022

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

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

第一部分:

""

第二部分:

""

我的简历

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的全平台开源阅读软件

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

前言

今年六月,亚马逊宣布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!
}

结语

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

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

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

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

二十九

在一个突然被剥夺了幻觉和光明的宇宙中,人就会感到自己是个局外人。这种放逐无可救药,因为他被剥夺了对故乡的回忆和对乐土的希望。这种人和生活的分离,演员和布景的分离,正是荒诞。所谓荒诞,就是没有上帝的罪孽。

——加缪《西西弗神话》。


杀手

“这里是二十九组,代号肆十肆。我已成功观测到了目标,隐身模式启动,任务开始执行。”

现在是二零二二年八月十九日,晚上八点半。灰白的云涂满了紫色的夜空,像重重浓雾将月亮挡在了后面。在潜伏的这一个多小时中,广州的酷暑用燥热和烦闷一点点消磨着我的耐心。我坐在台阶上托着下巴,观察着来来往往的行人,静静等待着他的出现。

参照出发前获得的任务情报,我此次的目标是一个男人。这个男人在过去几年已经陆续让二十六、二十七、二十八组的精英铩羽而归,所以今年轮到了二十九组的我。

而我们的任务很简单,就是杀了他。只要杀了他,我便能从这地狱般的酷暑中解脱。所以当视线捕捉到他的瞬间,我便迫不及待得站起了身,启动了隐身模式,跟了上去。

男人的身躯稍微有些前倾,脚上穿着一双白色的帆布鞋,在其上是卡其色的休闲裤和有些文艺的渐变色衬衫,即便是在昏暗的灯光下也能看到上面的那些褶皱。背后那单肩背着的硕大登山包,与他随处可见的平均身高相衬,显得有些不协调。他就这样一边走,一边和身旁的同事说笑、比划着什么。我对他们的对话产生了好奇,于是凑近了一点,也恰好得以在这夜色中看清男人的样貌——

他的头发有些长,有些卷,有些泛黄。虽然看起来很茂密,但风将他的刘海吹起时,却露出了那并不低的发际线。在男人甚至女人中都算小巧的脸庞上,是同样小巧的五官,美中不足的是那有些扁平和突兀的鼻子,还有那残余着一些痘坑的皮肤。一副黑框眼镜下分明有双清澈温柔的眼睛,却像是被刻意强化了攻击性一般锐利。不过总得说就是看起来比较年轻,不说还真猜不出是快三十的人了。非得说有什么特征,大概就是那远远就显露出的一股生人勿进的颓意,其他也没什么特别的。

但就是这么个普通的男人,让那些精英们全都铩羽而归。所以当长老说这次任务让我来的时候,我很是疑惑,毕竟作为一个常年的吊车尾,实在是难当此大任。不过我也没提出什么质疑,猜想来看大概是他觉得这个男人不值得再浪费人力了吧。但当然,我也还是要努力一下的,毕竟此次也不是毫无胜算——那些精英们自称“虽没有结果了他的性命,但也埋下了一些种子,待时机稍微催化一下,这事就成了”。

“也算是意料之中,技术并不意味着一切,确实要找落地点。”他们听起来像是在讨论工作。同事回应他:“你这思路对了,实用点是对的,毕竟公司给钱是为了赚钱。”他听到这个回复后,笑了笑:“确实,恰饭嘛,不寒碜,赚了钱才能去搞理想,被优化前多攒点钱吧。”同事也笑了笑:“确实,干啥都需要钱,但你也别给自己太大压力了,很多事就是看命。”

他们就这么说着走着,短短几分钟后,便来到了像是园区出口的地方。二人本来已经告了别准备分开,但同事此时却忽然说:“你今天丧得和以前不太一样,很久没听到‘理想’这个词从你的口中冒出来了。”他面对这句话,回应了一个苦笑:“可能是为了即将步行的六公里提个神吧,因为马上就是我的生日了。”

“……”同事沉默了一会,说了句“生日快乐”便默默离去。他望着同事的背影回了句“谢谢”之后,带上了口罩和耳机,叹了口气,径直得向右前方走去。

过了一个马路,经过了一个城中村,转了个弯过个马路,又经过了一个城中村。天气分明是如此炎热,路上的行人却还是络绎不绝。我跟了一路,一百米,五百米,一公里,始终都没有找到下手的机会。为了防止跟丢而一直落在他身上的视线,让我的眼睛疲劳了。于是我开始观察起他的行为,在这个无聊的过程找点乐子。

观察了一会后,我就准备收起前面的评价,他确实蛮特别的。这个特别之处在于他的行为——他总是过一小段时间就要做某种动作。用手挠挠自己的鼻子,抓抓自己的头发,还一定要左右对称各来一遍;将手揣到兜里,一定要有一小截露在外面,没多久却又拿出来;用中指撑一下自己的眼镜,即便它并没有任何松动;掏出手机快速滑动,打开一个个应用迅速浏览,又很快关闭;挺身调整自己的肩颈,转转脖子,不久又很快颓下去。这些行为夹杂着不时的叹气声和“麻烦,这个世界上的所有事情都是如此得麻烦”,让我不禁笑出了声:这个人怎么这么搞笑。但这搞笑之下,似乎又有着某种无奈。

我就这么跟着他走了一路,看着他过马路的不耐烦,看着他对着路边的猫吐槽,看着他进店买了个蛋糕,甚至还听他哼了几句歌……这种行进中的有趣观察让我入了神,所以在他忽然停下时,我竟有些不习惯。待回过神,我看了看周遭,发现正置身在一个天桥之上。此时正好四下无人,他正靠着天桥的栏杆,望了望远方,又望了望下面。

这是个好机会,背对着我的他毫无防备,当然也不可能有防备。我只需要向前走几步,将特制的匕首插入他的体内,便可在不知不觉间收割走他的灵魂。当然在此之前需要走一个程序,但既然他是组织所认定的目标,那这个程序也就是个形式罢了。

“那么,你就成为我职业道路上的第一个祭品吧。”虽然他听不到,我还是向他致辞表达了感谢。随后我便掏出了那把匕首,缓缓走向了他,就在离他只有半米距离的时候——

“你的手在抖吧?”他说着这句话,似乎同时瞥了我一眼。

“啊?”我停下了动作,看着自己的手。他说的没错,我的手确实是在抖,但这并不重要,重要的是为什么他能发现我。但当我再次看向他时,却发现他并没有看着我,只是转过身放下了书包,从其中掏出了一个棒状的东西。我忽然意识到这个让精英们棘手的男人并没有看起来那么简单,便警觉了起来,握紧的匕首能保证第一时间挡下攻击。

“哎,忘充电了,这点电还凑合吧。”这句话和他接下来的行为,让我陷入了迷惑。他把这个棒子和另一个棒子接了起来,摆弄了几下,又拿出手机,操作了几下,最后将手机放在上面对着自己,展开下面的棒子,立了起来。

“现在是八月十九号的九点左右,也是我二十九岁生日前的最后一个夜晚。”

他对着手机,开始自言自语。

受害者

周五晚上八点多,办公室内大多同事都已经离开了。我确认了刚才的BUG修复合入后,便将键盘放入背包,和联调的同事一起离开。刚走出办公室,我的眼镜就蒙上了一层雾。八月的广州本就闷热,加上今年反常的气候,很难不让人心生烦躁。

“唉,他妈的,好热。”我对同事抱怨。同事听惯了我的抱怨,附和了一句:“确实,出了不少汗。”

如果有个设备能够统计叹气次数,并提示过度换气折寿的风险,那我想必一定天天高危吧。不过这抱怨对我来说也是一种戏谑的出口,能有效缓解我的精神紧张。现在我的精神紧张确实被有效缓解了,随后我们向着园区的出口走去。

办公区离园区门口不远,大概三五分钟的路程。多云的天空没有月亮的踪迹,夜色比往常更暗一些,方才暴雨在地上留下的水迹反射着路灯的光,似一条因为被灼烧而痛苦向前扭曲爬动着的巨蛇。我们踩着它一边走着,一边扯着那么其实没什么意义的社交废话,像是工作,项目,合作之类的。是啊这一切都毫无意义,所以我为什么要工作呢?大概比起赚钱而言,我更多是将它作为一个和现实连接的锚点吧。

时间转瞬即逝,我们很快就到了出口。简短的告别后,他却叫住了我,说了声“你今天丧得和以前不太一样,很久没听到‘理想’这个词从你的口中冒出来了。”

确实,回过神来我才意识到刚才的交谈里这个词出现的频率太高了,于是只能用今天这个日子的特殊性来解释。我苦笑回一句:“可能是为了即将步行的六公里提个神吧,因为马上就是我的生日了。”他听罢便回了声“生日快乐”默默离去,而我则用“谢谢”回应了他的默契。之后,我便开始了预定的行程。

和往常打车不同,我决定顶着这个闷热,步行六公里回家。我戴上耳机和口罩,打开了音乐APP,放起了某位南京市民的专辑,走了起来。

而同时我也清楚得明白,有人正在跟着我,虽然他以为我看不到他。他的身高和我差不多,无视天气穿着的一身哥特风大衣,大概是为了他口中“隐身模式”吧。但在这肃穆的着装和其上那一头银灰长发之下,却是一张稚嫩的脸,虽然看不清他的那双眼睛,但直觉告诉我——这个人,和“杀手”大概没有一丝相符之处。

我装作没发现他,按预定走着。过了马路,经过城中村,转了个弯过个马路,又经过一个城中村。炎热的天气和背上比往常要重的包,让我有些难顶。我开始变得焦躁,时不时做出一些小动作。挠挠这里,刷刷手机,挠挠那里,看看手表,等马路时叹气吐槽,看到小猫时逗它叫。耳机中传来的歌声放大了我的感官,城中村内人来人往,穿梭的面包车、路边烧烤的炭火、理发师手中的剪刀、游荡的野狗、行人的眼神和窃窃私语,到处都是可以杀人的凶器。忽然,我的眼睛穿透了路边摊的烟雾,视线中显露出一个光着膀子的老板,我就这此景,不禁将听到的歌哼了出来——

我说老板,一斤尊严要多少钱。
我说老板,一斤理想要多少钱。
我说老板,一斤坚持要多少钱。
我说老板,一斤纯粹要……

唱到最后一句时,我正好过了一个拐角,刹那间一阵刺鼻的恶臭扑进了鼻腔,我连忙停止这廉价的感伤,捂着嘴快步向前走去。走着走着,当这味道消散时,我看了下面前的店面,好巧不巧,这是一个蛋糕店——我想着既然来了,就给自己买个生日蛋糕吧。

不一会,我提着蛋糕上了天桥,这标志着行程已经过半。我靠着栏杆,望着远方,又看了看下面,广州九点的天桥还是有一些热闹,但总有那么一会基本没有人,比如现在。所以这也是他动手的好时候吧,抱着这样的猜想我转过身瞥了他一眼——果不其然,他正手持“灵装”缓缓向我靠近。

“你的手在抖吧?”我把这句话抛了过去,接着放下了背包,将云台和三脚架取了出来组装好。虽然忘了充电,但应该还算能支撑接下来的拍摄。我把手机安在上面,打开了录像。

“现在是八月十九号的九点左右,也是我二十九岁生日前的最后一个夜晚。我正在步行六公里回家中途的天桥上,准备独自吃蛋糕。”

我对着镜头,将地上的蛋糕拿了起来,一边拆着,一边说:“理论上来讲,这作为生日蛋糕有点寒碜了,但我的要求没那么苛刻,所以也还成。这个芒果小蛋糕,因为我喜欢吃芒果,所以应该还挺好吃的吧。”这时我的非线性思维忽然又联想到了奇怪的方面:“我有个大学舍友叫王果,英文名是Mango。感觉有点好笑,我吃了Mango。”当然我知道这一点都不好笑。

说实话这蛋糕有点难拆,看来路边小店果然不太靠谱,但毕竟是在录视频,这个就不用说出来了。不一会,我拿起了勺子,在镜头前吃了两口蛋糕,讲道理口感还行:“嗯,味道嘛确实不错,但其实……毕竟随便买的,也不能要求太高,它已经非常努力地尽到了这个价位的蛋糕应尽的责任。”

如此一番后评价后,我将它收起了来。其实我也并不是真的想吃蛋糕,这种唾手可得的东西已经失去了稀缺性,对我而言吸引力寥寥。我之所以吃它,是为了将某种特殊的内涵赋予它后,来让整个视频有趣一些。

“我想想该说点啥,其实理论上也没啥可说的,毕竟要发表出来,那么很多可以讲的东西也就成为了不可说。”我想说点什么真心话,但意识到说了这视频必然会被删除后,还是克制住了。不过有些话虽然不能用清晰的逻辑表述,但却可以夹杂在胡言乱语中,毕竟疯子的呓语在拆解后,可能也是某种意义上的真理。

“不过来都来了,还是随便扯点啥吧,大家权当一乐。”我说了下去:“人们说,你不能有太多的情绪表达,这样只会显得你很不体面,你得克制自己,否则的话很多人会抓住把柄笑话甚至陷害你。说起陷害呢,实际上我也经历过一些,不过那些也都无所谓了。我只能说比起陷害来讲,可能笑话对人的伤害要更大一些。我来想想他们会怎么说啊……他们大概会这么说——”

我摆出了那副规训的样子,对着镜头嘲讽道:“你得换个干练的发型,穿得人摸狗样,语气要沉稳,少发朋友圈,没事别逼逼,圆滑世故一点,还得有点威严。这样嘛,才像个快三十的人男人,爷们,就要成熟稳重!”

结束这嘲讽的扮相后本来是应该笑的,但我却没笑出来,而是一脸严肃:“嗯,我本来这里应该是戏谑地笑。但看到镜头里我刚才装作他们的样子呢,我觉得其实更多的是一种怜悯。因为无论怎么说,他们也算是这种规训的受害者,虽然最后他们选择成为了加害者。因为你看他们扯了这么多有的没的,其实最后也就是一个意思——”虽然看破会让人觉得被冒犯,但我还是说出了他们的真心话:“你TM凭什么能活出自我?你TM凭什么敢和我们不一样?”

“而这句话的本质上来说……”我下意识想进一步解释些什么,但很快又意识到了没什么必要,便喃喃自语:“算了,算了。当然了某个著名的作家说,我们应当对狼显狼性,对羊显羊性,所以我在这里笑话一下也不过分吧?嗯,大概不过分。”随后又抬起头:“算了,不扯这些了,给大家唱首歌吧。”

我调了下云台的方向,一边缓缓起身确认着拍摄的角度,一边说:“这歌原曲是我校某位肄业的李姓学长,我加了几段自己写的词。能力有限,多多包涵!”

随后我便关闭了这段录屏,切换到后置相机站了起身,再次瞥了他一眼后转过身走到对面的栏杆旁,眺望着远处,唱了起来。

""
二十九岁生日前的最后一个夜晚,就给大家唱首歌吧。

这一曲道尽了我所有想说的,余下的只有沉默。我无言地关掉了录屏,将手机拆下,把云台收回背包中,打开视频号和B站分别发了个视频。当一切都结束后,我站了起来,天上下起了稀疏的小雨,四周的人影就像是在此刻忽然消失了一般。我撑起了伞,望着尚处于惊讶中不知所措的他,正式打了个招呼——

“Hi,这位跟了一路的仁兄,有兴趣和我聊聊吗?”

审判官

这个世界上有两类人:一类承认自己有罪,一类认为自己无罪。

承认有罪的人也有两类:一类认同自己被审判,一类拒绝自己被审判。

认同应当被审判的人又有两类:一类渴望自己被审判,一类恐惧自己被审判。

“我们一族的使命,就是去找到那些承认自己有罪,认同自己应当被审判,同时渴望自己被审判的人。然后去审判他们,去救赎他们,去满足他们的求死愿望。”

讲台之上,长老手持一本经典,向台下传授着自古以来的知识。阳光透过彩色的半透明穹顶,在书本的金属封面的反射下,映到了他因苍老而显得肃穆的脸上,像是蒙上了一层圣洁的纱。

台下尚且年少的我不知道他在说什么,只是根据朴素的善恶观,提出了质疑:“那么那些不愿意承认自己有罪,不愿意被审判,却真的有罪的人呢?他们不才是最应该被审判的吗?”

我已经不记得他那长篇的论证了,印象里尚存的只有他最后那伴随着无奈叹息的一句:“你不能苛求一个不愿意承认自己有罪的人认罪,也不能去审判一个不想被审判的人。记住,天堂和地狱的许诺只是妄言,我们要做的并不是审判罪恶,而是消弭痛苦,让那些活着就很痛苦的人得到安宁的解脱。”

“那么,让他们直接得到这样的解脱,就一定是正确的做法吗?”我并没有继续问下去,而是一直将其藏在了心中。大概也是因此,我便自一个资质靠前的绩优生,一路下滑到了吊车尾——虽然名义上通过了毕业测试,但却始终没有真正审判成功过任何一个人。

当然我明白,长老也明白,甚至首领应当也是明白的。我无法完成审判的原因,不止出于上面那些内心的诘问,还出于我和其他族人都不同的肉身。

我出生于一个与世隔绝的村庄,和其他同伴一样,我们从小便会封闭在村落里,学习一些宗教和哲学的知识;等了一定年龄,我们又会被教授一些关于真实世界的知识,了解人性的复杂;再之后,我们便会被授予一种“灵装”,进行成为所谓“代行者”的训练。为期两年的训练后,通过了代行者测试的人,便会被赐予我们这一族的使命,也是我们毕生的职业,其名为——

「审判官」

长老在赐予这个名号的时候,对我们嘱咐着,他说这个世界上所有人都是有罪的——对苦难无知而幸福活着的罪,拥有与众不同出身的罪,嫉妒努力抗争之人的罪,试图傲慢判决他人的罪,欺压弱小却对强大怯懦的罪,只要权力而舍弃责任的罪,等等……等等。有与生俱来的原罪,也有后天灵魂被污染的罪,而有罪之人,没有资格去审判别人。“但你们不同——”他说:“你们不属于这个世界,是作为‘无罪之人’被培养的,所以你们可以去审判,审判任何组织让你们去审判的人。”

此后他再三强调:“切记,你们是不属于这个世界的人,这是你们无罪的根基。一旦和世界产生了归属,你们就不再无罪,失去了使命的你们,恐怕只会彷徨于世,或是成为更大的灾厄吧。到时候,我们便不得不来清理门户。”

这忠告,或者说威胁,却仍然无法阻止同伴的好奇心。不少族人在执行审判过程中被引诱堕落了。他们有的被抓住清理门户,有的躲在暗处担惊受怕,有的则结盟反过来对抗组织。这类人在近现代后的比例忽然多了起来,是组织规模变小的主要原因之一。

除了堕落者,还有一些唾弃着长老们的规训的分裂者。他们认为“有罪但想逃离审判”的人越来越多,所以组织不应该再满足于传统的训诫,而是要高举正义审判之杖,去审判那些“应当被审判”之人,尤其是那些失去了良知的大人物。于是他们以“不承认自己有罪就是最大的罪恶”为旗号,去启动了自己信念中的审判,但结局是如此的显而易见,甚至都不太用动脑细想——他们不是堕落了,就是被对方用现代的法律设计陷害了。

那么我呢?这还用问嘛,要不你以为我是怎么当上吊车尾的。在二十岁那年,我和同伴们一起参与了代行者测试。我被告知要被传送到一座陌生的城市,去审判一个人。不错,他们用的都是“审判”这个词,但在我看来这和“杀人”无异,所以理所当然,我没有完成这个测试。

依稀记得那是在测试的尾声,已经知晓自己失败的我静静站在大桥上,望着方才被我救下的小男孩那离去的背影。风从我的身旁缓缓拂过,不知何时来到了我身边的长老拍了拍我的肩膀,说着:“小伙子,这已经是难度最低的任务了。从物理的难度来讲,小男孩无法反抗你;从精神的压力来说,他的不幸已经足够令你没有愧疚。但你仍然没有完成。”

当时的我还年少,面对长老还有些露怯。我只能低着头愧疚地说:“对不起,我下不了手,因为我看到了,我可以改变……”没待我说完,便感觉头上传来了温柔的触感,他语气和蔼,缓缓说着:“我们能洞悉此刻的罪业,却看不到宿命和因果,但你可以,所以才会更加犹豫。这是你的天命,所以我们不会责怪你,但作为长辈也还是要提醒你一句,你认为的救赎,对于他而言,可能意味着更大的苦难。”

自以为是的救赎,可能意味着更大的苦难;对他人命运的干涉,则意味着无法逃避的责任。那时的我没有理解他的言语,我行我素地做着不断的“救赎”,以救赎取代审判,曾是我引以为傲的成就,但直到某一天……

算了,不提这个,面前的这个男人已经唱完了他说的歌,感觉时间也差不多了。我会尽力去处决他,虽然我的手在抖,但我仍然会尽力。这并不是说我忽然想通了自己的职责,那句话也不过是调笑——事实上我早已不再审判,也不再救赎,单纯摆烂混日子罢了。

我之所以想审判他,只是因为在我观察的这段时间内,他展现出的那表层戏谑下浓烈的求死愿望,文明面具下挣扎嚎哭着的兽性,以及过于清晰坚定的宿命所根植于的混沌罪业,久违地激起了我血脉中的本能。

但就在我下定决心再次动手的时候,他却说要和我聊聊。

罪人

我知道他在想什么,也知道他想做什么,所以我说“要和你聊聊”。但具体应该聊什么?聊聊我为何承认自己有罪,为何认同自己应当被审判,为何渴望着审判?不,这些东西他一眼就能看出来,所以,我应当聊点别的。

那么我应当以什么角度去聊呢?从为自己辩护的角度来看,我应当论述一些努力拼搏的事迹,一些挣扎着赎罪的实践。在过去的一年内,这种事情确实也不算少,我随便在大脑中搜索一下,便可以摘录出几条:

  • 我参加了一些公益,看了一些纪录片,更加深入了解到了那些被社会所遗忘的人,回忆起了一部分曾经的自己,破除了象牙塔中天真的傲慢与偏见,开始认定那些精英并没有他们宣称的那么智慧和崇高。
  • 我更加不再在乎他人的目光,淡然消解了一切无论出于什么目的的攻击,以怜悯取代了对那些躲在暗处的无能之人的憎恨。没错,我明确这是一种傲慢,但已经知晓了“对羊显羊性,对狼显狼性”的我,认为这是合乎正义的。但作为相对的代价,我也失去了无条件信任他人的那种可贵的天真。
  • 我仍然保持着在工作上的动力,虽然之前的小组解散重组了,但我仍然很快转换目标并轻车熟路推进了xr-frame的架构和初步落地,在职场上仍然能够做出一些有价值的实事。
  • 我不再对先天缺陷自怨自艾,努力学会了游泳,重启了点阵激光的疗程,尽可能保持身体健康,并和家里摊牌表达了互不干涉的勉强和解。现在的我比起让所做的事情达到“完美”,更看重它的“完成”。
  • 我仍然坚持着理想,完成了企划Project SelfProject Love,以及独立游戏Project Tomorrow的第二版剧本。虽然前二者的结果都不尽人意,但也能够坦然接受而非愤怒、悲伤或是遗憾。因为我明白,我只是单纯想完成它们借此对人生做一个回顾和总结,哪怕仅仅是当做利用平台影响力完成的一次破除偏见的演讲,也足够作为努力的回报了。

我甚至还可以将Project Love中的一段话原封不动搬过来,渲染一下气氛:

好了,我们终于离开了隧道,来到了下一段行程。大家请看窗外,那湛蓝的天空上挂着一轮太阳,阳光透过前方熠熠生辉的错落云层,投射在这片青色的大地上。这里从瞬光有意识开始便一直存在,本是荒芜的大地在他从外界引入的溪流的灌溉下,长出了永远不会枯萎的草、永远不会凋谢的花。而在这片大地上最显眼的,应当是那一开始就存在的,名为“真理”的大树。大树上结满了鲜艳诱人的果实,年少懵懂的他不知道这果实意味着什么,便摘下了一颗吃掉,但这口中的甘甜却伴随着剧烈的腹痛,苦不堪言的他从此便远离了这棵树。而从这隧道驶出后,他又来到了这里,压制住恐惧再次摘下了一颗果子,却发现上面赫然写着“代价”二字。一声苦笑之后,他还是吞下了果子,但那预期的腹痛并未到来,取而代之的是更加的清醒、敏锐和通透——

  • 他不再怨恨出身。因为他知道了世界本源的不平等,个体在时代中无力,人本性的复杂与矛盾。但这并不意味着原谅,在一次“我已经支付了足够的代价,知晓了对错,找到了自己的路,而你们的代价需要自己去承受”的坦诚对话后,互不干涉,是他最终选择的和解方式。他虽然无法选择过去,但可以选择自己的未来。
  • 他不再嫉妒别人。因为他知晓了自己吃到的红利,兴趣和资本相符的幸运;也了解了太多光鲜背后的包装,大多人的德才不配位。他现在认为吃到了红利的人应当承担更多的社会责任,去做一些超脱于利益的事情。
  • 他不再容易动摇。因为他明白了“欲戴皇冠必承其重”,知晓了代价为何物并选择承受,清楚大多数在时代境遇下的压力和宣泄的出口,人不可能被所有人理解,明确了自己愿望和信念是值得坚守的。
  • 他不再厌恶自我。无论是过去、现在还是将来,他接纳了自己的一切。因为他相信是过去的种种塑造了如此一个复杂、丰富而真实的“我”,让自身有了表达的诉求,有了表达的能力,有了人生厚度的可能性。
  • 他不再折磨自己。因为他知道创造的前提是体验,而刻意的苦难充满了矫饰。他开始适当享受生活,不再吝惜钱财,虽然仍然对物质的比较毫无兴趣,但不会再委屈自己。

但在完成这些听起来很动人的游离思索后,我却有些反胃,便什么都没说,也什么都不想说。我只是劝他先不必急于这一刻,请他收回手中那个看起来挺危险的“灵装”,随后示意他来我身边,看看桥下的景象。

“刚才的蛋糕有点腻,我有点想吐,嗯,就吐到下面那个正在过马路的西装小哥身上?”我捂着胸口,脖子前倾,做出呕吐的姿态,而他也如意料一般制止了我,倒不是因为什么道德要素,单纯只是怕麻烦:“当心人家上来揍你,我可不想去审判一个狼狈负伤的人。”

我想他说的也有道理,便只是望着那个小哥,望着他走过了马路。他也看了看小哥,随后又看了看我,望着我那戏谑的表情,眼神中有些不解:“你认识他?”他的疑惑是理所当然的,我当然不认识这个小哥,也对他个人没什么仇恨,所以按照实情回了他一句:“这小哥估计学历不错,打扮看起来像是搞金融或者市场的?不过这大热天的走夜路是有点稀奇。”

“虽然不知道你的意图,但你是在嫉妒吗?听说现在社会上你们这行的地位很低,远不如金融之类的,虽然……”说到这,他似乎有些犹豫,而我知道他在犹豫些什么:“虽然从你的洞悉看来,‘嫉妒’这个东西在我现在那混沌的罪业中根本不值一提,对吧?”

他显然被这回复震惊到了,迅速和我拉开了一步的距离,拿出了灵装,保持着警戒的姿态。我看着这样他,不禁笑了笑:“比起你的那些前辈,你倒是有趣很多,让我想起了曾经的自己。”我的视线随着举起的右手一起望向夜空,在一句祷告后,本来被遮蔽的月光穿透了云层,一柄权杖显现于其中,它缓缓降落,最终停在了我的面前。

“我对那个小哥确实没有嫉妒,只是忽然想起了过往的回忆。”在进入下一个议题之前,必须要先解决上一个议题,这是提议者的责任。我接着说:“在我很小的时候,有个成绩和我差不多的朋友问我‘为何而读书’,我认为‘我读书当然是为了获取知识,改变世界,让世界变得更好。’但听到这个课本上标准答案的他,却有些诧异,说‘不对,读书是为了考试,考试是为了好学校,去好学校是为了好工作,获得更多的资源’。”

“你想表达什么?自己追求境界上的崇高?不…比起这个你手上的那个东西。”他直勾勾得盯着我手上的权杖,这并不奇怪,他当然应该对它很熟悉。

“我只是想说,一时的眼界和格局不代表永远,还要看阅历和时机。有时候你的认知越清晰,同类就越少,如果这时候你还想保持初心和底线,那同类就更少了。当你意识到了善意的言说可能会成为一种傲慢,于是就只能沉默,沉默,会导致和社会联系的断绝。”解决完这个话题后,我轻微晃了晃手中的权杖,发着微光的粒子有节奏得从它顶部充盈着月光的宝石中缓缓释出,像是在奏响一曲哀伤的挽歌。

“好了。”在他诧异的目光中,我微笑着说:“现在审判官有两个,接下来该怎么办呢?”

审判

他是我的同族,我对此没有丝毫怀疑,他那手中的灵装便自动宣告了这个结论。如此一来,我如此渴望杀掉他的冲动就得到了解释——他大概是一个叛徒?不……我想,并没有这么简单。我也曾遇到过堕落的同族,但并没有任何一个如他一样复杂,也没有任何一个能在堕落后还能召唤出灵装。

“现在审判官有两个,接下来该怎么办呢?”他手持权杖,微笑着对我说,而我也明白这不是挑衅。众所周知,在审判的现场,有两个审判官,从平衡的角度而言,就一定有两个罪人,而现场又只有我们二人,所以我们必然都是罪人。

我们都意识到了这点,所以几乎是同时,我们互相承认了自己罪人的身份。如此一来,我们便既是审判官,又是罪人。在这种戏谑而尴尬的些许僵持后,他像是看出了我的疑惑,先开了口。

他说他并非和我一样成长于那个村落中,这也解释了为何我对他毫无印象。想起来在我得到的知识中,确实有名为“失落之子”的存在,这一般用于指代那些因为各种原因被遗落在现实社会中,遗忘了自己使命的同族。但从书本中的描述来说,这类人终其一生都不会觉醒自己的能力才对,他们大都只会按照社会的规训,过完自己的一生。

当听到“失落之子”这个称呼时,他的情绪没有任何波动,只是淡淡说了句:“你们书本中的论述,未免太过简略了吧?”接着论述起了一些我从未知道的东西。他说无论成长于何处,无论是否意识到了自己的血脉,我们一族的基因中都有着与世俗不相容的特性——天真和敏感。对于审判官的使命而言,这是一种天赋,毕竟要洞悉罪业,消弭苦难,首先就先要觉察罪业,共情苦难。我们所受的一切教育和训练都是为了避免落入普世价值的陷阱,避免产生移情,来防止自己在罪人的悲痛中沉沦。

但他们不同,他们并未受到过训练,于是这天赋便成为了纯粹的负担。他们天生容易被美德诱惑,容易去对课本上那些真善美坚信不疑,容易对他人产生一种天真的信任。所以他们在失去了象牙塔的庇护后,就更容易受到严重的创伤。因为这世上早已充斥着肮脏和腐朽,手越是脏就越容易上位,而所谓稀缺的财富美貌,也不过是在掩饰对命名权、阐释权和破坏权的争夺。

听完了他说的这些,我便理解了他那混沌罪业的来源。毕竟就算以我对现实社会浅薄的观察,普世价值也早已沦为了光鲜的谎言。一个人是否承认自己有罪,是否认同审判,是否渴望审判,其标准并非是那早已成为笑柄的普世价值,而是他自身的信念。持有的信念越是崇高,就越可能在现实的妥协中产生深切的罪感,这种生存和理念间的矛盾,终会导向强烈的求死愿望。

我之所以如此理解,比同族的大多人都理解他,并不只是出于我能看破宿命的可能,而是因为——我现在也是如此。作为对他这份坦诚的回应,我也以罪人的身份,向着作为审判官的他,供认出了自己的罪行。

那时的我尚且年少,自觉与众不同。我是特别的,和那些只知道用“杀”的手段来审判世人的同族不同,我拥有的是“救赎”的力量。我能够洞悉罪孽,同时也得以从中看到改变宿命的可能,在那众多的命运流向中,我总是能找到最适合对方的的那一条,借此让对方脱离苦海——那时的我是如此确信,如此意气风发。

而这一切的转折,是在我第二次在任务中遇到她之后。

她是一个二十多岁的女人,当我作为审判官被传送到了一个天台时,她正站在天台的边缘,向下探着身。接下来的事情毫无悬念,我“救赎”了她,她流泪感谢了我,说自己一定会按照我的建议,去努力走出一条不一样的道路。她相信了自己的才能,知道了自己适合做什么,拥有了改变的意愿。“那接下来必然会向我曾经救赎过的那些人一样,至少会的平稳顺遂的一生吧?”虽然我从未知道过那些我救赎过的人的结局,但我仍然如此坚信着,直到我第二次遇到了她。

几乎在传送结束的同时,我就认出了她。她正站在一条河边,沉默望着水面。我一时百感交集,不仅由于她因罪业导致的苦难更加深重,更是由于她的所有的命运支流都枯竭了,这远远偏离了我的预期。我茫然地望着她,问出了“为什么?”她的回复让我至今难忘:“对不起,我已经足够努力了,我尽我所能,用我所长,去获得回报,去赚钱。但我赚得越多,他们就认为能索取更多。我压力越来越大,已经撑不住了。”

“然后你一定会说‘为什么不舍弃他们?既然他们是如此的肮脏。’”他戏谑着打断了我的供述,而我虽然有些生气,却没有任何反驳的立场。那是我第一次杀人,这违背了我的信念,从此我便有了罪业。对此,他的审判是公正的:“这个世界上存在着许多无解的命题,你所谓的‘救赎’只是一种傲慢的表象。浅薄的认知下看到的并不是真正的命运,而是一种似是而非的臆想。你和你的其他同族一样,从未真的感同身受过,当然也没错,你们本就不该感同身受。”接着这个判词,他继续说起了自己的故事。

他说因为诸多为了得到而失去的,为了不失去更多而失去的代价,从某一年的生日起,他的求死愿望便愈发强烈,所以那些精英就来了。精英们想要执行审判,却在审判程序中发现了他血脉中的异样——他是同族,却又不是叛徒,不满足处决的条件。所以他们只能不断尝试唤醒他的血脉,不断拔高他的信念,让罪感更为深切,最终引发他的自灭。但他们没想到的是,他不但没有自灭,反而觉醒了能力。

作为罪人觉醒的他,比起我们反而没有了规训的束缚。他说就在觉醒的一瞬间,自己仿佛对很多事都通透了。憎恨化为了怜悯,恐惧化为了戏谑,嫉妒化为了坦然,唯有些许愤怒和悲叹尚存。他不再对任何人产生无谓的期待,不再相信他人口中廉价的承诺,不再信任自己以外的所有存在。

“唉。”我望着他,不禁发出了叹息。因为我终于明白了自己为何想杀了他——不是因为“同族”,而是因为“同类”。觉醒后的他和我一样,不但能看到罪业,还能看到宿命。不仅能看到他人的宿命,还能看到自己的宿命,再也无法忽略他人就算是善意的谎言,再也无法用那块红布蒙住自己的双眼,这是何等的可悲啊。但在叹息中的我出于好奇,仍然问出了一个问题:

“你用过这个力量,或者说你想用这个力量,去审判别人吗?”

“说实话,这个力量对我而言没什么意义。”他摇了摇头:“你很清楚,罪人没有资格去审判另一个罪人。这世界早已不存在无罪之人,我可以去悲叹愤怒,可以去蔑视戏谑,可以去敬佩那不自量力的抗争,可以去追随那微弱耀眼的星光,却唯独不可以去审判。我不会去审判任何人,也不会让任何人来审判我。”

在这句回复后,我们相视一笑,各自收起了手中的灵装,随后聊起了最后的话题——宿命。

宿命

我们相视一笑,收起了各自的灵装。他看着我将包背起,和我一起慢慢走下了天桥。此时已经十点多了,空气中的燥热散去了大半,街上的行人寥寥,微风和昏暗的路灯营造了一种静谧。我们就这么走着,谈论着那个主题。

对于“究竟什么是宿命”的问题,我们展开了讨论。虽然过程中出现了不同的见解,但最终还是达成了统一的结论——宿命最根源的底色是荒诞,它不是一种固定的状态,而是导向一个大致结果那流动的过程。我们在无知中被抛在世,背负着几乎是不可能的期许,对万物的摄入决定了行为,行为带来了选择,决策又要求着代价。于是我们支付了代价,得到了一些,再往后我们又支付了一些代价,却不是为了得到什么,而是为了不失去更多。

我说,一切的观察、言说和行为,都会在时间的尺度上表现出某种形式。而最后的意义,最终的宿命,就是这形式接连不断的运动。一个人的言行在当下是否一致,在不同的时间是否具有一贯的内核,往往可以阐释出他宿命的基调

“那么,你的基调是什么?”他笑着问我。我觉察到了他这明知故问中的不怀好意,但我并没有生气,因为这意味着他大概真的将我当做了朋友。对于朋友,我只能袒露出真诚。

我望了望晦暗的天空,又低下头沉思了些许,最后向他摊了摊手。我说,我曾以为我的基调是悲壮的,后来又认为是戏谑的,但现在我自己也不清楚了。不过按照方才的结论,宿命本身就不是一种状态,而是形式的运动,所以我便论述起了这形式中已经或是将会存在的一些碎片。

碎片其一为「自我」。十四岁是第一个结点,二十八岁是第二个结点。我历经了童年的懵懂,少年的憧憬,青年的迷失,在对成长漫长的抗拒、对世俗和圆滑的鄙夷与恐惧、以及最终仍然到来的成熟后,进入了下一个“确信”的阶段。伴随着内心的平静,我唤出我的三个人格投影,以写真和访谈真诚记录了当下的状态。

碎片其二为「图腾」。“象征”并不只是所谓伟大存在的特权,作为弱小个体的你我,同样拥有着宣言的权力。我们体内的宇宙并不比自然的那个渺小,内心的律令也并不比头顶的星空晦暗,那就堂堂正正地宣称吧:我作为一个真实的人,将利用我现在的所有技能,以陪伴了我八年的博客改版为起始,去构建出那属于自己的图腾。

碎片其三为「明日」。对他人苦难和创伤的指责是一种无药可救的傲慢,讲述现实的人必须拥有对应的资格。所以我以自己和朋友们的经历为蓝本,去挖掘了一种90后原生家庭创伤的共性,以独立游戏的形式,在男女主平等视角下向大家献上一部个人在时代中的现实主义悲剧。而在完成剧本的过程中,我也加深了对苦难的理解:苦难就是苦难,并不值得歌颂。某些人妄图用一些“体验”去猎奇,为了表达而去刻意寻求,来换取一种对他人优越的道德权力,这不过是廉价劣等的自我感动罢了。

碎片其四为「寻根」。一只无脚且恐高的鸟儿是不能停下的。我害怕迷失目标,便只能一直望着前方。我不能失去那对一切的爱和憎恶,那种彻骨的冷漠和抓心的热忱,否则我将不再是我。如此精疲力尽飞了许久,我终于在通透的那一刻明白了:我之所以如此,大概只是因为作为没有来处、没有根的流民,只能不断寻找一个在现实中的“锚点”吧。所以我将在三十多岁,预计物质和心态都准备就绪的时候,逆着成长的漂泊之路,探寻自己从何处而来。

碎片其五为「追寻」。即便不知来处,目视前方的不断追寻仍然是有意义的。自大学起就在创作、但却搁置了许久的这个近未来科幻群像AVG,角色们展现出的那些理想、友情、牺牲和爱,作为某种意义上的起始,真正的践行了我一直记在心中的火村夕的那句:“这个世界上没有奇迹,有的只是偶然和必然,以及是谁在做些什么。一直期望着能出现奇迹的人们是不会发生奇迹,只有想要用自己的双手创造奇迹的人们,救赎之手才会伸向他们。”

碎片其六为「心灵」。人的心灵很容易改变,却不可能恢复原状。如果想要保持初心,就尽可能不要去改变它。但随着年龄的成长,这种不愿改变的执拗就会成为现实的阻碍,于是越来越多的精神障碍患者便产生了。患者中必然会出现医生,医生也会成为患者,但他们却只能医人却不能医己。这样一群人互相的理解,慰藉和救赎,在游戏的载体下,想必一定会成为动人心弦的故事吧。

碎片其七为「伪物」。世上有两种边缘人。一种不太为物质困扰,却在精神上疏离于所在的主流社会;一种在精神渴望融入主流社会,却因为物质压力挣扎。在他们的脑海中,所处的环境和自身必有一个是虚假的,而环境是客观存在的,所以虚假的只能是自己。于是自称为”伪物“的他们为了在生活中运作下去,只能不断和自身的精神内耗搏斗。那么这两种有着共性、内核却完全不同的边缘人相遇后,又到底会发生什么?这个故事用影像和小说的双视角论述再为合适不过。

碎片其八为「诅咒」。成长伴随着期望,被他人所期望,被自己所期望。然而没有人是理想和完美的,试图培养圣人是一种残忍,追求世事的无缺是也是一种残忍。被要求完美的期望,本质上是一种诅咒。所以在没有被给予成为完美的资源,却又一直被要求完美的人,是背负着诅咒成型的,如我一样,如你一样。我直觉上认为,应该有一种装置艺术,能将这种诅咒那模糊的感受重现。

碎片其九为「旅途」——

我顺畅地描绘了前八个碎片,但到这最后一个的时候,天边却响起一声炸雷,闪电如火舌点燃了云层,似有瓢泼大雨将要倾泻而下。好在此时距离小区仅余两百米,我示意他跟上我,快步向前。短短三五分钟,我们便进了小区,进了电梯,进了我家,坐到了沙发上。

家中稍显杂乱,窗外淅沥沥得下着雨,两只猫在阳台对着我们奶声奶气叫着,但这一切并没有将他从方才的话题中拉回现实。他推测着我最后一个碎片,说这是对于之前所有碎片、或者说是那个形式的最终阐释。他还说,这是一条真正的理想主义之路,它不仅要求着观察和思考,更要求着行动。你要完成,就要良好生存,为了良好生存,就要去获取资源,为了获取资源,就要妥协,妥协撕扯心灵,会产生病症,病症带来痛苦,痛苦导向死亡。

“唉,这样的一生必然阻力重重,未免太痛苦,太可怜了。”他再一次叹息,而我也知道,他仍然在尝试洞察我的宿命,想找到一条轻松愉快的路径,虽然我们都知道那是不可能的。而且他不知道的是,我也在做类似的事,只是比他看的更透彻一些。

我说,理想主义者可能会痛苦,但绝不是可怜的,况且你怎么就能确定前方只有痛苦呢?结局可能是注定的,毕竟人固有一死,意识消失的那一刻,这世界对于你我也就不存在了。但即便如此,它也不应当是理所应当般得在戏谑中结束,而应当是结束于“我已然拼尽全力,这就是最后的终点了”的坦然。从有意识开始,“失落之子”的血脉就让我不断思考着那些大问题,思考着为何要去表达,但思考得越多就遗忘越多。终于有一天,我想起了我本来就只是想要表达而已,并没有那么多冠冕堂皇的理由,于是我自己背负上的那些责任便不再沉重,而成为了一种新的力量。

这个力量,就是生存的价值,活着的意义,就是“宿命”本身。对我来说是如此,对你来说也应当是如此。

他听着这些话,低下了头沉吟了片刻。“但是……”他抬头看着我,犹豫着想要说些什么,却最终将话咽了下去,久久沉默不语。我们就这样对视着,直到零点的闹铃响起——那是一阵钟声,像是在惋惜,又像是在祝福。

“唉,看来任务是失败了,我也该走了。”他站起了身向我告别,那最后的表情是在微笑,所以我也用一个笑脸作为了最后的回应。

然后,他启动了传送,离开了。

明日

肆拾肆号回到了组织总部,汇报了任务的失败。长老对这个结果并不奇怪,就像是往常一样象征性训斥了几句,便让他回去休息。他本应如常一样回到那温暖的房间,来缓解肉体和精神承受的双重压力,等待着下一次可以预期失败的重复任务,但他这次却没有。

他昂首挺胸,眼神锐利,神情坚定,语气铿锵有力:“长老,我不想如此下去了。我不想再走马观花得作为其他人生的观众,然后去做什么审判。我想亲自去体验,去找到自己真正的宿命。”

长老先是有些惊讶,他质问面前的青年是否真的知道这意味着什么,质问着拥有这样血脉和能力的对方是否对体验现实的代价有着清晰的认知。但这些问题全都被青年的渴望所化解了,所以最终他也只能摇头叹息:“如果你最终堕落了,应该会是个巨大的灾厄吧,那时候,恐怕只有我亲自动手了。”

他谢过了长老,表示自己会承担所有的代价,然后准备次日抛下了那个代号,取回自己的名字,离开养育自己近三十年的村庄。在出发前的这个夜晚,他最后一次躺在从小睡到大的床上,看着透过窗户的夜色,看着桌上堆叠的那些讲义,看着墙上挂着的象征着使命的十字架。他忽然觉得这些熟悉的存在变得非常陌生,就像是第一次出现在他的认知中一样。他直直望着天花板上的那盏吊灯,就像是在看着某个人的眼睛,说出了最后没有说出的那句话:

“但是和你展示于外的‘确信’的道路和‘选择’的权力不同,我的‘眼睛’看到的却是你深深的焦虑和巨大的压力,在无数个深夜的煎熬与挣扎。你仍然拒绝了一切的帮助,没有后盾,没有退路。你所想抓住的一切,仍然都只能靠你自己去抓住。你所言自己坚信着的既定宿命,其实不过是众多路线中的一种而已。分明还有许多种可能,你却仍然选择成为一个如此的宿命论者。说了这么多理由,找了这么多证据,但其实真正原因只有一个吧——因为从你的审美来看,这样的生存方式是最为美丽动人的。”

“但我还是想问你,我在你灵魂深处所窥见那满溢着的‘恶心’,究竟是什么?”

青年H目送了肆拾肆号的离去,他用力后仰张开手臂伸了个懒腰,打着哈欠关掉了闹铃。他看了看时间,觉得现在作为周五的失眠之刻还尚早,便坐到电脑前,解锁了屏幕。

他先是打开了Foobar2000,准备来点音乐提提神。在犹豫片刻后,他在几千首中选择了觉得最适合目前氛围那一曲,放了起来。他本应立即去做些什么,去写小说,或是做游戏,或是改版博客,或是看一看书,但他就这样坐着,直直地望着天花板。雪白的天花板上仿佛没有一丝灰尘,正中的那盏吊灯像是一只巨大而明亮的眼睛。他看着那只眼睛,那只眼睛也在看着他,他忽然觉得有些想吐,便起身冲到了厕所。在干呕了几声后,他回首一望,却发现自己仍抬头坐在那里,便意识到了这反应并非源于肉体,而是他的灵魂。

“真他妈恶心。”他的灵魂一边呕吐,一边嘶吼——

对苦难无知而幸福地活着的人真他妈恶心,带着天赋出身并理所当然的人真他妈恶心,对自己的时运没有自知之明的人真他妈恶心,不学无术在象牙塔俯视众生的人真他妈恶心,对努力抗争的勇者施以嫉妒嘲讽的人真他妈恶心,德才不配认知浅薄还试图进行判决的人真他妈恶心,对弱小耀武扬威却对强大摇尾乞怜的人真他妈恶心,只贪图享受权力舍弃义务的人真他妈恶心,无视事实故意输出情绪节奏的人真他妈恶心,利用权威愚弄群众只为私利的人真他妈恶心,拥有才能却只知道玩弄发明帽子的人真他妈恶心,具备环境条件却不愿提高认知只知发泄无知的人真他妈恶心,用自己肮脏的内心随意揣测中伤他人的人真他妈恶心,用崇高光鲜包装外衣行苟且之事的人真他妈恶心,消费他人痛楚来谋取道德利益的人真他妈恶心,对外冠冕堂皇却在匿名下散发恶意的人真他妈恶心,顺风享受光鲜却想让他人为低谷和过错买单的人真他妈恶心,擅自期待只会伸手乞求施舍的人真他妈恶心……

偏见令他作呕,嫉妒令他作呕,傲慢令他作呕,怯懦令他作呕,卑劣令他作呕,崇高令他作呕,不公令他作呕,虚伪令他作呕,规训令他作呕,愚蠢令他作呕,抨击令他作呕,崇拜令他作呕,谎言令他作呕,真相令他作呕……

而最是令他作呕的,是容许这一切存在的世界,以及意识到了这一切却无能为力的自己。

他将五脏六腑都呕出来,又全部咽了下去。疲惫的灵魂被肉身拉回,视线中还是那个摄人心魄的眼睛,音箱传来的婉转伴奏下是沉稳的歌声:

命运来了
它带着天平给每个人算命
我看着它 睡着了
我曾经那么无知地
鄙视它 诅咒它
如今我跪倒着苛求个机会
它看着我 笑了
于是我唱

来来来…来来来来来来…来来来来来…
来来来来…来来来来…

不管曾经或是以后拥有是什么
请你相信我 我还会唱歌
或许生死或许悲观离别是什么
亲爱的兄弟 我还会唱歌
不管永远或是现在会有些什么
请你相信我 我还会唱歌
不管永远或是现在会有些什么
请你相信我 我还会唱歌……

“但这个不加粉饰的真实世界就是如此,这用宿命谱写挽歌的人类就是如此。我只有尽我所能,努力去成为这挽歌中偶现的、戏谑和悲叹之外的一些什么。”

“那就继续吧,继续吧,直到终结降临的那一刻前,一切都必须按照那独一无二的剧本进行下去。”

然后,他本应反抗却又不得不期待的明日,如常到来了。

Project Self

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

“对对对,就是这个姿势,保持一下!老天赏脸,这光真不错。”身后的摄影师@夏昊(以下简称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,我太清楚自己的拖延和惰性了,但早已根植于我内核的“决定了就一定要做好”的诅咒,却让我不得不花很大精力去反抗我的本能。这一次次细致的规划和设计,无不伴随着极大的焦虑和数次的失眠,客观来讲这对我的身心造成了不小的负担。不但如此,我之所以会这么早就做好尽可能全面的规划,同时事必躬亲不喜欢他人插手,应当还是出于一种深刻的“不信任”——

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

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

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

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

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

我为什么写作

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

回顾2021

若按大学毕业以来的惯例,这个新年我应当又化身“青年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岁的男孩子特有的的笑容,无忧无虑,带着坚定的希望。

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

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

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

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

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

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

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

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

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

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

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

“......”

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

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

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

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

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

灵魂探测员

本文是南周《虚构写作课》大作业。第一次写不擅长的题材,背景设定太宏大,十晚完成限一万字,哎尽力了,我还是去写我擅长的弱小个体面对荒诞世界的存在问题吧。


“不错,我是昨天跳的楼,四月四号,也是我的生日。”午夜,一栋办公楼挡在高悬于夜空的月亮之下,遮住了两个身影。林森如往常一样身着制服,伪装成大楼的保安,站在设备“灵魂探测仪”旁,执行着工作。作为一个资深灵魂探测员,他对流程早已麻木,工作全凭经验带来的敏锐:“四月四号是前天,而且资料显示,您是八月七号出生的。”

探测仪好似一个大号的户外手电,正在发出一种特殊的光。这光不为人眼所见,却能让灵魂显现于其中。灵魂本身并无固定形态,全靠探测仪按资料形象进行模拟。此刻现林森面前的,是一个三十来岁,西装革履的上班族。他很绅士得低了下头,表达了歉意:“抱歉,老头子,记性不好。”

这致歉让林森有些惊讶。在他的漫长的工作记忆中,无论对方身份,总是要么瑟瑟发抖,要么张牙舞爪,要么疯疯癫癫,这么有礼貌的并不多见。于是他立即抛出了一个问题,借此对灵魂的精神状况进行评估:“您,明白现状吗?”按照自杀所的员工准则,对方必须理解现状,被诱导出动机和遗憾,最终被施以宗教层次的关怀,确信自己会去往往极乐世界。

“当然明白,我死了你却能看到,你一定是道士!”

这个回答让林森有些棘手。他虽然遇到过不少疯子,但反客为主揣测自己身份的却是第一个。这让他有些慌张,又觉得有些滑稽。但出于职业素养,他首先想的仍然是“如何用道士身份进行宗教关怀”,但又很快否定了这个想法。他绞尽脑汁,能想起的也不过是儿时看的那些粗制滥造的僵尸片。

“你不是道士?唉...那看来不能被超度了。”对方见他没有回应,先是摇了摇头表示遗憾,随后便向后张开双臂,表情狰狞,大喊着奔向了林森:“那就从你开始咯!”

“卧槽,什么情况!”林森从未见过这阵势,虽明知对方并不能奈他何,却还是本能向后退了一大步。在恍惚中,听到的一句“回家...看床下”更吓了他一哆嗦。待稍过片刻,他终于冷静了下来,想起了自己的工作时,对方却已不见踪影。

“靠,麻烦了。”对于林森来说,狼狈倒是其次,麻烦得是他此次工作的失败。由于工作全程会被录音并实时上报,蒙混过关也不太可能。到了他这个级别,工作失败的影响很大,别说是唾手可得的假期要泡汤,就连绩效也要打折扣。所以他并未轻易放弃,而是立马提起探测仪到处扫视,但对方就像是刻意避着他一般,始终没有露面。

“艹,只能认栽了吗。”如此辛苦了一个小时,还是没有任何进展。他确定了任务的失败,关掉探测仪悻悻而去。

大概四年前,凭借高于常人的共情能力,林森被分配到了自杀所。自杀所设在灵魂研究院下,专门处理自杀事件。开始他就像个刚入行的医生,一方面恐惧着作为病人的灵魂,一方面却也更加耐心。他全身心地倾听灵魂的心声,为他们的经历感到揪心难过,甚至还会捐助亡者穷困的家庭。

这善意让灵魂们知无不言,却也为他带来了极大的心理负担。医生业界有个共识“要保持善良,但不要共情”,这同样适用于他的工作。年轻的他并不懂得这一点,便不断忍耐,保持着极度高洁的品格。但精神问题如何能一直忍耐?终于有一天,他爆发了。

那日,林森如常在深夜来到了某个高楼之下。“又是跳楼,唉。”他叹了口气,却又很反感这样的自己:“不行不行,我要保持关怀。”之后便打开了仪器。探测很顺利,显露出的灵魂是个男性,清瘦,长发,有点颓废。

“您是,王波?不要害怕,我是来为您祈祷的。”在惯例的开场词后,他没有得到预期的回应,而是一句冷漠的 “祈祷?这是我的选择,不需要祈祷。”听到这句话,林森沉默了些许,年轻的他无法理解对方的言语。但出于要求,他还是提出了问题:“您为什么要自杀?”这问题是他工作内容最重要的部分。自杀所的主要职责是探明死者动机,交由上层统计社会问题来影响决策。

对方轻蔑得给出了一些回应,大致是“活着没意义”,“虚无主义”之类的。并且还莫名其妙跳起了舞,口中哼着奇怪的曲子,脸上洋溢着的笑容,仿佛死后确实更快乐一般。这引起了林森极大的不适,击溃了他一直忍耐着的情绪:“就因为这个?什么狗屁‘活着的意义’。我见过重病想为子女减负的老人,备受同学欺凌无处求救的孩子,努力给家人治病却被拖欠工资、孩子也没救回来的农民。而你...!”

“唉,想个屁啊。”凌晨两点半,林森回到了家,放下重重的探测仪后,灯都没开便瘫在了床上。方才被死人的戏弄引发了他对第一次失败的回想。他之后被送去精神科测出了中度躁郁,停职治疗了大概两个月。后来在一个前辈的告诫下,他决定不再倾注同情心,工作却反而越做越好。

“不错,工作和生活就是要分开。”他开了灯,去冲了个澡后准备睡觉,但躺在床上后,失败的懊恼又浮现了出来。他翻出了今天这个灵魂的资料,边看边回想起方才的对话,总觉得有哪对不上。“老头子?”他看到了资料中的年龄,又想起了对方的自称,瞬间明白自己被耍了。

“靠!被摆了一道。”他把资料扔到一边,坐起身抓着头发仔细思考。灵魂形象只会根据资料模拟,理论上确实有被顶替的可能。但据他所知,今天在那跳楼的又确实只有一人。退一万步他也想不到被顶替的动机,毕竟灵魂探测技术目前对公众还完全保密。

在这深深的思索中,他忽然想起在被“攻击”时,恍惚中听到的那句“回家...看床下”。好奇心终究超越了恐惧,他鼓起勇气趴到地上望向了床底。而床底也并未出现他臆想的鬼影,只有一些垃圾。

“唉,收拾下吧,反正也睡不着。”自得病后他懒散了不少,便对这种犄角旮旯有些疏忽。他拿出扫把,扫出了一堆垃圾。而就在将这些垃圾扔到袋子里时,他却发现了一个奇怪的东西。这个东西是棒状的,看着有点像权杖,黑色的躯干有金属的质感,一个透明的玻璃球被扣在了躯干上。他努力回想,却还是没有任何关于它的印象:“难道...又是那次失忆?”

大概半年前,林森失去了一段记忆。那天他醒来后发现自己正躺在病床上,医生告知是昨天下午他突然晕倒在办公室。原因则是他经常在晚上工作,作息不规律。他有点迷糊,告知医生自己没有昨天的任何记忆。医生表示这很正常,可能是晕倒脑部被撞遗失的。他只能相信医生,接受了这个答案。

“算了。”他停止了回想,继续琢磨着手中的这个玩意。他盯着这个头部的玻璃球,越凑越近,越凑越近...“擦!”忽然,玻璃球猛得发起了光。他反射性得将其扔到了床上,随后传入耳中的是熟悉的声音——

“认证成功,启动中。”

这句在耳中回响过无数次的声音让他错愕了,在这错愕中,忽然有另一个声音从背后传来:“小伙子,看这。”他心中一怔,下意识的说了句:“方博士?”却在转身后更加震惊:“您这是,什么情况!?”

方博士是自杀所的高级研究人员,与林森这种一线人员相比,他拥有更高的地位。不同于其他领导,博士对林森很友好,事业上给到了不少帮助和关注,甚至前些天还到他家来做过客。而让林森震惊的是,博士现在却看起来就像个被探测到的灵魂。

“如你所见。”除了颜色,博士的形象和平常一致。一具瘦弱的身躯被白大褂裹着,背有点驼,波浪状的长发披散着,遮住了挂满胡渣沧桑的脸,一副宽边眼镜下是狡黠的笑容:“如何?我的新发明。”他走到了床前,试图拿起那个权杖,却落了个空。“唉瞧我的记性,死都死了。”他摇了摇头,又走到了林森面前,用敏锐的目光盯着他:“小伙子,你是不是有很多困惑?”

“当然!”林森就着对方疑问,脱口而出:“工作失败什么的都不重要,我根本想不通顶替这个死者的目的。不...在这之前,您的自杀的动机是?退一万步来讲,现场也没发现您的尸体啊?还有真正的死者在哪,难道是被您赶跑了?”他一股脑将所有疑问全部抛出,语气中除了疑惑,还夹杂着一些愤怒。

“呵呵,问题很多。”博士笑了笑,并没有正面回答,而是指着床上的装置:“这是我最新发明的‘灵魂探测仪便携版’,能耗比极致优化,全球独此一份。你猜猜为什么我要用它和你见面?”

林森立即通过直觉给出了答案:“您不想让上面知道?”标准的探测仪会实时上报录音,以此避免工作中探测员勒索财产等。但虽然能明白结果,他却给不出动机:“理由是什么?不...应该说,您到底在计划着什么?”

“不错,很敏锐嘛,就和半年前的那天一样。”博士将手踹到了兜里,走到了墙边,示意林森坐下:“你的记忆删除是我执行的,不过我留了一手。所以在失忆后,你经常会做一个梦。”接下来,博士还描述了梦的一些细节。在那个梦中,林森看到了很多像培养槽一样的装置。此时有人在他耳边说了些什么,他听不清却很震惊和愤怒,随后便会惊醒。

博士所言毋庸置疑,丝毫不差。林森被这信息量所冲击,沉默了好长一会。但受过训练的他本能得加以了抗拒:“这不奇怪,这些在我接受精神状况的测试中,都一五一十交代了,您作为领导肯定是知道的。”

“唉,看来他们的洗脑还真有用。”博士叹了口气,随后清了清嗓子,压低了声线,神情严肃:“算了,我能存续的时间不长,就直接说吧。我的计划很简单,就是毁掉这个研究。”这发言让林森更加懵逼,但博士丝毫没有顾忌他的震惊,紧接着解释起了自己的动机:“你以为我们的工作是高尚的,是在减轻逝者痛苦的同时暴露社会问题。但其实恰恰相反,我们的双手沾满了罪恶。”

这句话当然招致了林森的反对,他虽然还不明白博士在说什么,但却切实觉得自己的工作和人格被侮辱了。他想给对方打上被NGO渗透想来破坏自己信念的标签,但又觉得不合逻辑,便只能恶狠狠地盯着对方,一言不发。博士望着林森,像是看穿了他的想法,反问一句:“小伙子,你太天真,真以为国家会倾注这么多资源,就是为了所谓的人文关怀?”

“......”林森明白,对方言语是有合理性的。在这个时代,人类的处境确实比较艰难。化石能源存量不多,核聚变又一直没有成熟,能源危机越来越近,即便是他所在的一线城市,也开始试行了节约用电。这时候灵魂研究院的成立确实不符常理,但他只是一个社会底层,平时也无暇顾及这些宏大叙事:“上层的决策,自然有他们的道理。”

“他们的道理?呵呵,你做了这么久探测员,也算见识过人间百态,你觉得他们的道理是什么?”博士没有等待林森的回答,而是继续说了下去:“剥削,压榨,篡夺劳动果实,一直都没变过。”

“博士,您的发言很危险!”林森反射性得提醒着博士慎言,但有转而想到现在并没有人在监听,便提出了一个尖锐的问题:“您说的这些是没错,但和您这种既得利益者又有什么关系呢?”在他看来,就算是抱怨这些东西,也应该由有资格的人来,比如他超度过的那些灵魂。

“既然你这么着急,那我就告诉你真相吧,还有你那失去的记忆。”博士望向了窗外,深邃的双眼中似乎在回想着什么:“这对你会有些残酷,毕竟作为业绩最好的探测员,你带回了那里至少五分之一的灵魂。”说完这句话,他又看回了林森,眼神中充满了怜悯:“可以理解你当时的选择,但这一次,你没有逃避的权力。”

“别再整这些谜语了,我到底忘记了什么?!”受够了博士的喋喋不休,一股无名火窜上了林森的头。他站了起来,冲到了博士面前,试图抓住他,却狠狠撞到了墙上。便只能捂住手回到了床上。

“你先睡觉吧。”

“睡?现在?”林森觉得自己被戏弄了,恶狠狠地盯着对方。

“是,睡觉,我来帮你恢复记忆。”

林森没有别的选择,只能相信博士。他本就已经很疲惫,很快就睡着了。随后博士走到了床前,用手触摸了那个装置的头,瞬间化作了一道光,被投射到了他的脑中。

雪落在地上,白茫茫一片。林森睁开了眼睛,意识还有些朦胧,他望着这漫天大雪,呆呆得站着。

“您站在门口干什么?快进来啊。”少女清脆的声音从前方的大门中传来。林森听到了这声音,想起了自己的身份,便提起了手中那个笨重的仪器,走了进去。这座大楼属于灵魂研究院的自杀所,他作为一线人员,负责出勤采集灵魂的数据。

现在是下午两点,林森通常在这时来送修探测仪。由于尚在测试阶段,探测仪每次使用后都需要及时送修,否则便会无法开启。他业务能力出色,已经探测了上千灵魂,检修已是家常便饭。他熟练地提起探测仪,将其放在前台,转身准备离去。但少女却拦住了他:“方博士让你去找他。”

“方博士?”林森回忆着这个名字,感到熟悉又陌生,那是在入职培训时出现过的名字。他问了问理由,但少女说自己也不知道,只是告知了见面地点。“好吧,我…?”还没说完,他便感觉眼前一黑,待回过神已坐在了一个椅子上。对面是一个男人,他瘦削的身体穿着白大褂,波浪状的长发中是带着胡渣的脸,黑框眼镜下一双犀利的眼睛正审视着他:“小伙子,你接受不?”

这质问让他不明所以:“接受什么?”他大概知道对方就是方博士了,但思维还是有些混乱。

“呵呵,注意力这么不集中。”对方和蔼得笑了笑,指着墙上的屏幕:“你拥有了晋升的资格,不过要先通过一个考验。”

“等等,我不太明白,我...”这次又没说完,林森的眼前再次一黑,回过神时又到了一个新的地方。

“我再问你一次,你确定要知道真相?”林森的身边仍然是方博士,但神情和方才不同,严肃的表情透露着一股沉重。

“真相是...?”林森一边回应着这个唐突的问题,一边观察着周围。他分明从未来过这里,却又感觉有些熟悉——这是一个十分宽阔的场所,数量庞大的胶囊状设备排列得错落有致。它们和科幻片中那些放克隆人的设备有点像,但并非透明也没充满液体,而是被银色的金属包裹。在设备上方,有一个不大的显示器,似乎在播放着什么内容。他仔细观察着最近的画面,却难以理解:“这上面放的是什么?”

“看看右下角,你不记得这个男人了?”

“右下角?”顺着博士的提示,林森仔细看了眼右下角,那写着一些文字:“这!?”惊讶中,他盯着画面上的男人端详了稍许:“陈...华?他为什么会在这里?”他认出了这个男人,这是半个月前他在一座大桥下“超度”的灵魂。

“这就是真相。你以为自己‘超度’了他们,但不过是将他们‘收集’到了这里罢了。为什么探测仪技术都两年多了,还必须要每次用完都送修?”

“我不明白。”博士的说辞让林森感到困惑,一直以来,他都严格相信着员工手册中的内容:“他们的自杀理由我已经问得很清楚了,任务完成得很彻底,为什么还要收集起来?”

“唉,小伙子,你太天真,这会吃大亏的。”博士叹了口气,摇了摇头:“你真的相信在这个时代,国家投入这么大的资源,就是为了搞个人文关怀和什么统筹?”

“那还能为了什么?这人都死了,还能有什么其他价值?”

“眼见为实,不要走神。”博士示意林森继续看屏幕:“这会对你很残忍,不过也没办法。”

“?”林森带着疑惑,聚精会神盯着屏幕。画面中的陈华带着一个小孩在公园内散步,这应该是他所言早逝的儿子。他拉着孩子的手,两人都洋溢着幸福得微笑。忽然乌云蔽日,狂风大作,父亲下意识护住了孩子,想要带着他到某处躲避,但孩子却不肯走。在男人疑惑的神情中,孩子向他说了句什么,便化作了泥土散去。男人先是错愕,随即重重得跪到地下,十分悲痛,接下来——

“这TM到底是怎么回事!?”仅仅是一瞬间,林森的心态便发生了天翻地覆的变化。转身抓住了博士的衣领,眼睛瞪得浑圆,双手颤抖。

“......”博士没有立即回应,打破沉默的是设备中无机质的提示声:“编号799号实验体能量回收完毕,产出高于平均值。”

“够了,就到这。”博士没用什么力便轻易挣脱了束缚,随后打了个响指。

“够什么!你...嗯?我...”林森忽感一阵眩晕,陷入了昏迷,而当再次睁眼时,方才的场景早已不在,有的只是熟悉的天花板。他瞬间明白了一切,迅速从床上跳了起来。

“这就是你一直重复的那个梦,也是你失去的记忆。”博士先一步回到房间了房间,盯着还在错愕的林森。

“不可能,这太离谱了。我们的关怀其实是在把他们推向地狱,就为了可笑的能源研究?”林森回过了神,紧紧攥着拳头,对着博士怒目相向:“你能侵入我的梦境,伪造也不是什么难事。说吧,欺骗我的动机是什么?我现在就去报告所里!”

“我能理解你的愤慨,不过——”博士丝毫不在意对方的威胁,他再次走到床边,用手靠近了那个棒状设备,随后在林森面前出现了一个画面,上面是一个小女孩的照片:“记得她吗?”

“你是什么意思?”林森一眼就认出了小女孩的身份,这让他非常不解:“还想编纂我‘收集’的第一个灵魂的惨况,进一步打击我?!”即便到现在,他和小女孩的沟通还历历在目。

“方诺,灵魂编号44,溺死时仅八岁。”

“别念了,你到底是什么意图,你…!”听到博士念出的信息,林森更加愤怒了。他转身便准备对着博士破口大骂,但看到对方脸的瞬间却停下了:“您…是在哭?”这个一直保持着冷静的男人,此刻却挂上了慈爱的表情,任由泪水沿脸庞流下,静静盯着那张照片。林森认得这个眼神,当他给为人父的灵魂看子女的照片时,他们就是这个眼神。

“44,4.4,四月四号,姓方,难道...”林森推理出了什么,他的愤怒完全消解了,取而代之的是一种更强烈的错愕:“您...”他想问些什么,确说不出口。

“这一切的错误,都是从我开始的,所以我要毁掉这一切。”

说完这句话,林森身后传来奇怪的声响,他连忙转身,发现是方才的画面放起了录像。

白色的房间,一个柱状设备矗立在中心,角落看起来是控制它的装置。设备上有一个关着的屏幕,侧边的绿色指示灯表示运作状态。

“博士!状况不太对!”忽然,门打开了。一个身着防尘服的女人匆忙走到控制台前,操纵着仪器。不一会,一个男人进来了,径直走向了柱状设备。

“通信模式已启动,我先出去了。”女人撂下一句话便关门离开,仅剩“博士”一人。

“实验性功能启动,连接成功,通信对象,代号44。”这句语音后,屏幕打开了。画面中是一个小女孩,看起来只有七八岁,扎着双马尾,有一双纯洁的大眼睛。她穿着病号服,却站在花园中,抱着手中的玩具熊嘟囔着嘴:“爸爸,我不开心。”

“怎么啦?不是说好了听医生的话,好好休养吗。”方才还阴着个脸的博士露出了慈祥的笑容,语气十分缓和:“只有好好休养,才能治好病呀。”

“我知道…但是,我好难受啊,爸爸。”小女孩像是要哭出啦一般:“他们都在欺负我,但他们又好可怜。”

“他们?”博士收起了慈祥的笑容,换上了疑惑:“他们…是谁?”

“就是他们,我看不到,但能听到在向我说话!”小女孩终于忍耐不住,哭了起来:“我觉得他们很痛苦,一直在说着我听不懂的话,虽然听不懂,但诺诺知道那是在骂我!什么cuanduozhe、budechaosheng!”

“……”博士像是在思考什么,沉默了稍许:“糟了,难道那个猜想是真的!”

“爸爸,诺诺听不懂。”小女孩还在继续哭:“但我真的好难受,你能让他们不要再骂我了吗?”

“对不起诺诺,他们都不是坏人,你再稍微忍耐一下啊,爸爸这就去和他们交流。”

“可是,他们现在就在骂我啊…爸爸,你不能在这里和他们说吗?”

“对不起诺诺,爸爸会尽快…”博士攥紧了拳头,语气中充满懊悔。

“爸爸…你个人渣!”忽然,小女孩像是换了个人一般,语气中充满了恶意。

“诺诺?你在说什么?”

“你这个败类,骗着说要超度我,却只是在折磨,让我死后都不得安宁!”

“好痛,好痛啊!不要再撕扯我了,我是谁,我到底是谁,好难受!”

“爸爸…我好难受,大家都好难受!”

“诺诺…诺诺!”博士盯着屏幕中的女孩呐喊着。她已经难以维持自己的形态,不断变换着形象,直到越来越模糊,最后——

播放停止了,画面也就此消失。

“这就是第一次灵魂延续实验的失败。”博士的声音将林森从影像中拉回了现实,他恢复了冷静和克制:“将一个灵魂能量抽离,补充到另一个灵魂上延长存续时间,没想到灵魂本身和记忆就是一体的。”

“结果呢?难道说和…”林森正想询问,但转眼想到了梦中那个男人的结局,便没有继续说下去。

“失败。但这失败却成了灵魂能量提取技术的里程碑。”博士说明了后续的一切。当时的状况被自动上报,他作为主要研究者,得到了组织的慰问和嘉奖,不久便晋升了高级职称,得到了技术团队的管理权。他大力推动了这方面的研究,进一步发明了“梦境入侵”等技术,直到前天——他用自己业余研发的自杀装置抹去了自己的存在,劝退了真正的死者的灵魂,顶替他来到了这里。

“但…如果这一切是真的,你为何要助纣为虐?”

“因为,这是诺诺的期望。”博士伤心欲绝,一开始想立马辞职走人。但那几天却一直在做同一个梦,在梦中,女儿一直在祈求他,祈求他能够救出那些被折磨的灵魂。他明知女儿已经完全消失了,却还是愿意相信这是在给自己托梦。“真可笑,我一个无神论者。”他苦笑着:“但我相信诺诺真的是这么想的,她是个很善良又有使命感的孩子,就像她妈妈一样。”

“既然如此,您为什么不直接停掉整个研究?”

“这种项目是不可能停的。在这种非常有潜力的能源面前,压榨几个死人算什么?”博士说明现在的能源困局很严重了,所以上面极其重视这个项目,自杀所则是重中之重:“灵魂越痛苦,湮灭时产生的能量就越强。自杀的人往往有非常强烈的求而不得,更容易操纵,这也是让你们去调查自杀动机的真正原因。”

“艹,这帮狗日的。”林森终于理解了博士一开始说的“我们的双手沾满了罪孽”。

“不仅如此,他们还尝试用梦境入侵技术,利用灵魂想要行善的愿望,去让他们在梦中传播死后真的能去乐园的想法。如此一来,底层那些受苦的群众便会传播开达成共识,增加自杀者。”

“这有什么意义?他们光鲜的生活本就是我们的艰苦换来的,等底层都死光了又有谁来供养他们?”

“这个简单,宣传口已经开始工作了,一方面‘为了国家多生孩子’,另一方面‘到年龄主动自杀是给孩子减负’。同时伦理方面已经在考虑放开安乐死的限制了。”

“……”林森哑口无言。

“小伙子,在利维坦面前,你我都不过只是渣滓罢了。不过——”博士扶了扶眼镜:“他们恐惧的,也正是人民。”他进一步解释,在这三年,他利用规划设施的权力构建了一个系统。这个系统的启动需要足够多的能源,所以必须要收集足够多的灵魂。但一旦启动,就能利用梦境侵入技术,让能被网络连接到的人们的梦境相连,广播一些信息:“但这些信息并不是完全随意的,它们需要提供能量的灵魂湮灭前,拥有一个共同的信念。”

“所以您的自杀,也是这个系统必要的启动条件…”

“这是我的选择。”博士没有正面回答林森的问题,而是又将一个画面展露在他的面前:“时间差不多了,来帮我点忙,你不能拒绝。”

“我没有逃避的权力。”林森仔细端详起着画面,这上面是一张图纸,他看一眼就明白了,这是要改造探测仪,将一个棒状物体放入某个位置即可:“这个位置,难道是预留的?”

“不错,检修员将其插入收集装置时,会开启隐藏权限。我会利用这个权限去完成最后的工作。”

“嗯交给我吧,不过我还有一个疑问——这个帮手,为什么要是我?”

“你很有才能,却又同时保持了发自内心的善良。你知道真相后深深的自责和愤怒,以及对晋升的果断拒绝,证明你是最佳的人选。这个计划的实施和善后都很需要能力,但最重要的是善良和使命感。”博士表示这次行动肯定会被上层知道,也必然会牵连到林森。不过他早就安排了后路,并在随后将路线告知。

“善良和使命感啊…我明白了。”林森明白对方说的没错,既然知道了真相,便已经无法在逃避,这是他的本性。他按照博士的指导,顺利完成了设备的改造。

天亮后,林森走进大厅提交了探测仪,便准备离开后按照博士给的路线出发。离开大门前,他转身最后望了一眼这个自己工作了四年的单位,又想起昨晚和博士的对话,瞬间百感交集。这时他便隐约觉得自己步入了宿命,一种赎罪的宿命。

很快,探测仪被顺利得送到了回收舱。一秒,两秒,三秒,“检修中”的语音传出,博士的提权操作也随之完成。他进入了中控网络,做着最后的准备。

方博士是什么时候失去信仰的?他已经记不太清了。最可能是在妻子因为某些活动入狱后,他为了保护女儿选择离婚的时候吧。但是随着女儿的逝去,和女儿的请求,在谋划的过程中,他觉得自己似乎又找回了信仰。

为了让灵魂们拥有一致的信念,统一爆发出能量,需要有一个合适的剧本。他自然明白对于底层的失意者,最合适的就是宗教。他尝试了各种宗教,编排了很多故事,却始终难以让自己信服。这种状况僵持了很久,直到某一天,他注意到了书架上尘封多年的几本红色的书。

他随意抽出了其中的一本,大致翻了翻,却发现了自己当年做得密密麻麻的笔记。此刻他终于回想起了,回想起了年轻的自己,回想起了为何自己会追求当时的妻子。他也回想起了,有一种信念根本就不需要编纂,但凡体会过生活的苦难,或有起码的良心,只需要一些基本的言语,便已足够让人理解。

“然后,夜晚来临,太阳升起。”

还被束缚在虚幻幸福中的灵魂们,终于失去了现有的一切。他们在各自的温室中,被孩子,被母亲,被友人,被伴侣,用温柔的方式告知了真相。这是博士的设计,他们也明白这是设计,但还是坦然得接受了——毕竟是在不抱任何期待中逝去的,这延续的幸福,即便是虚假,也已是奢侈。更何况在这之后,他们将承受极大的痛苦后化为可悲的燃料。

而在这诀别后,他们被聚到一片荒芜的戈壁。上千的灵魂,立于这荒漠之上,注视着高悬于天际的鲜红太阳,像是在等待着什么的降临。

“起来,被烙上诅咒的人们。”

忽然,一首国际歌响彻整个空间,连太阳都在为之颤动。

“生前受尽压迫,死后还要被榨取最后的价值,这就是这个时代,我们这些建设了整个世界的无产阶级的下场。”

伴随这歌声,一个洪亮的声音响起。

“那些可恶的资本家,那些官僚!生前剥削我们,篡夺我们辛勤劳动的果实!死后还要压榨我们,将我们的痛苦当做柴火!”

“天杀的!”“狗娘养的!”“不要脸!”受演说所影响,部分的灵魂开始咒骂。

“他们不但要迫害我们,甚至还想对我们的亲人,我们朋友,那些我们所爱的人下手!”

“要阻止他们!”“打倒他们!”更多的灵魂加入了诅咒。

“对,我们必须要打倒他们!但仅凭我们是做不到的,我们需要更多人的帮助!虽然我们的生命已经到了头,但信念却可以传递下去!”

“只要埋下种子,总有一天,英特耐雄纳尔一定会实现!”

这最后的宣言正好和歌声结尾互相映衬,灵魂们的情绪也达到了高峰。他们一个接一个爆炸湮灭,却没有任何人感到恐慌,这是一种自愿的牺牲。拥有着强烈信念的灵魂们爆炸后,都化为了耀眼的金色粒子,向着鲜红的太阳汇集而去。

博士的计划,成功了。

那一夜,几十亿的人们都做了同样的梦。在响彻云霄的国际歌中,他们明白了这个世界的真相,明白了生前和生后都会被压榨的事实,明白了自己的阶级和境遇,也明白了真正的出路是什么。此刻的他们中有盼望着一些人来领导他们的,也有正摩拳擦掌准备大干一场的,当然也有一些投机分子,还有恐惧和不屑的官僚与资本家。无产阶级们终于明白了,该如何来获得真正的权利和尊严。

“英特耐雄纳尔,就一定会实现!”

星星之火,可以燎原,但前路必然艰辛。此刻已属于某个组织的林森,在梦中和他的同志们带着热泪,跟着唱完了这首歌。他们下定了决心,不惜一切代价,也要完成几个世纪前没有完成的理想。在他们的心中有一个共同的信念——

如果一种文明是靠榨取和剥削大多数,来为少数人享乐服务,那还不如就此毁灭。

WebGPU实时光追美少女

""

作为一个老二刺螈,我进入这个行业的最初动机可以追溯到十年前打通了《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实时光追美少女】踩坑与调试心得

本系列文章设计的所有代码均已开源,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实时光追美少女】降噪与色调映射

本系列文章设计的所有代码均已开源,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与蒙特卡洛积分

本系列文章设计的所有代码均已开源,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与射线场景求交插值

本系列文章设计的所有代码均已开源,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前夕离职的天宇

本文是南方周末《非虚构写作课程》的作业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

本系列文章设计的所有代码均已开源,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实时光追美少女】场景数据组织与合并

本系列文章设计的所有代码均已开源,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基础与简单渲染器

本系列文章设计的所有代码均已开源,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和工作流

本来这里想顺便说说资源和工作流部分的,但篇幅已经太长了,就放在下一个章节讲吧。

❌