MoreRSS

site iconelmagnifico | 云浅雪修改

程序员,架构师,无人机集群表演设计师,嵌入式工程师,maya插件开发者,多智能体研究者,独立游戏爱好者。
请复制 RSS 到你的阅读器,或快速订阅到 :

Inoreader Feedly Follow Feedbin Local Reader

elmagnifico | 云浅雪的 RSS 预览

北京自驾游

2026-05-11 00:00:00

Foreword

车在北京,顺便周边自驾转转,看看北方发展成什么样了。从北京去了保定,打算从西南边逛起,再回北京见同学,接着往西北边逛了一圈,然后直奔秦皇岛。

保定

保定虽然就在北京旁边,自古以来也是守卫京畿的重地,主要景点基本都在中心城区。但保定给我的感觉和去粤西、揭阳那会儿差不多:有点破,有点穷。

古莲花池

没啥看头:不是莲花开花的季节,园子又小,又是北方路子,确实没法跟南方那种细腻的园林比。

直隶总督署

保定要拱卫京师,权力最大的衙门就在莲花池对面:直隶总督署。清代不少头面人物在这儿办过公,曾国藩、李鸿章、袁世凯之类,依然没啥看头。

白洋淀

image-20260511002652804

白洋淀去得太晚,只剩游船,别的景区进不去了,倒也够用。我坐的是木游船,满员6人,人力摇橹——没听错,人力——正常一圈大概两小时,离谱。后来船工集体摸鱼,换快艇给拽回去了。景色倒是没啥特别的,就是芦苇荡;芦苇也不够高,荷花也还没到开的时节,反倒是柳絮重灾区,张嘴就能吃饱。

芦苇密的地方确实跟课本写的一样,船钻进去就找不着了。这片红色景区不少,我都没去。游船泊到一座孤岛,全员上岸,船工歇口气;岛很小,就几个卖货的,随后又被直接拉回码头。

雄安新区

雄安之前看过不少介绍片,以为建设得多唬人呢,实际一看也就那样:路也就三车道,楼也不高,估计限高了。生活配套更少,只有几个偏一点的地方人稍微多一点。路边要么还是林子,要么正在施工,总体挺空。

悦容公园

悦容公园别当真以为是给老百姓的——连个像样的停车场都没有,能是给百姓用的吗?说白了就是旁边那块是给领导配的景,站在高处往下看用的。园里设施也锈迹斑斑,挺破败。

北京

之前没想到北京周边能这么穷、这么奇葩:很多路都是单行道,路旁大白杨大柳树,清一色砖房和老房子。路况也一般,跟广东村里那种路没法比,进城才好一点。

园博园

园博园很大,我只顺路扫一眼,基本没人,里头不少地方在维修,五一搭的临时设施也在拆。

image-20260511003702039

本以为园内电瓶车能开着兜一圈,快速扫完,结果被坑惨:自带导航解说,嗓门巨大还关不掉,一路社死。避障估计也有毛病,走一路报一路障碍,龟速,还不如人慢跑。无语。

价格更离谱:起步30块半小时,之后一分钟一块钱,咋不去抢。园里还有多人自行车,看别人骑也一样遭罪。

image-20260511004850054

园博园太老了,2013年建的,这么多年维护跟不上,不少园子、建筑都斑驳了。22年起改免费市民公园。

首钢遗址

image-20260511004539750

之前以为首钢赛道在北京,没想到在秦皇岛;北京这边好歹还有首钢遗址公园。停车场刚好在冷却塔底下,头一回挨这么近的冷却塔,确实凉快。

image-20260511004635781

永定河畔

image-20260511005015186

首钢旁边就是永定河,传说中的永定河畔,现在是一个大公园

石景山

image-20260511005131757

巧了,石景山也在同一个地方,石景山很矮,不费力就登顶了,山上就一点古建筑,也没啥特殊的了

妙峰山

image-20260511005227113

北京比较出名的跑山地界是妙峰山,这次也去遛了一圈;还有个红井路,人称北京秋名山,这次没时间没去。

妙峰山前半段还能跑起来,后半段尽是短促回头弯,不敢猛踩油门。

image-20260511005408713

山顶就那么回事,一块平台,能看出有人在这儿玩过漂移,人很少。也俯瞰不了北京,看的是反方向。

西二旗

中国互联网除了深圳南山、上海,剩下大头基本在北京西二旗。刚好傍晚过去,人爆炸多。这几天在北京开车没觉得电车有多少,一到这儿密度陡增,路边人也密。

同学顺带领着转了圈小米西二旗总部,挺好:食堂便宜得离谱,比深圳城中村还便宜。最近挺火的小米价值观冰淇淋好像卖光了,没吃上。

鸟巢

鸟巢能买票进,还能登顶;对面水立方、奥林匹克塔、玲珑塔、中国考古博物馆一串都能看见。

image-20260511010552992

一晃鸟巢都快二十年了,内里不少细节也显旧了。

image-20260511010517369

长城

image-20260511010252354

八达岭长城头一回去,晚上就住山脚下,条件很挫,只能叫宾馆;好处是第二天出门就是索道,上去省一大截腿力,再爬一小段就到北城墙顶,大部分城墙都能俯瞰。

长城确实有巨物感:在这种山脊上砌砖墙,工程量难以想象;更关键的是长度吓人,长得远处城墙我都拍不清。

明十三陵

image-20260511011049142

明十三陵有神路、昭陵、定陵、长陵四处联票;我实际走了神路、昭陵、定陵,长陵放弃。

神路值得去一趟,算十三陵门面,巨物感拉满,气派,但实质内容不多,只能说后人维护得太整齐。

昭陵人最少,我去的时候现场就我一个,随便溜达。昭陵基本上把陵墓该有的建筑凑齐了。

定陵卖点是地宫,地上部分跟昭陵一套模板。地宫要下八九层楼的样子,里面没啥看头,一股橡胶地垫味,不值得专程来。

长陵跟昭陵类似,多一个大殿,遂跳过。

十三陵本是一大片皇家陵区,如今被拆成几块卖票;总览图上看仍然气派,龙头龙尾龙山那一套,皇家叙事拉满。

小米汽车超级工厂

顺路参观了一下小米汽车超级工厂,确实很牛逼,至少给看的压铸车间,人很少,91%的自动化率,车间内基本都是靠机械臂和AGV完成每一个工序,而且工厂并不仅仅是平面的,立体的也有用到,车间的转运都走在高处。参观的人一波接一波,各种政府或者机构的人都过来参观或者试乘。参观还是有点粗,讲解人员也只是背稿,实际东西并不是很理解,总体车间参观大概是30分钟左右,全流程45分钟,后面就是介绍一些大家都知道东西。

同时工厂的食堂也对外开放,这个食堂倒是和深圳的小米食堂差不多

image-20260511021338730

巧了,之前没吃到的冰淇淋,这里有,但是服务人员还是有点偷懒,三个不一样价值观的饼干,她给一样的,被我说了,才给我其他的

image-20260511021353899

秦皇岛

秦皇岛离北京大概三百公里,不到三小时能开到。

北戴河

image-20260511011128386

「这么近那么美周末到河北」口号喊得响,北戴河真到了也就那样:典型小吃街,同一种小吃重复五六个摊位;傍晚海风老猛了,外套都扛不住。北戴河实际是戴河以北那片,主打避暑、度假、赶海、沙滩。

阿那亚

image-20260511013349916

阿那亚分南北区,建议住南区:北区小,打卡点多数在南区;北区也相对穷一点,住户更集中在那边。两区隔着大马路,运营方默认你不靠腿,得坐穿梭巴。园区大,内部有摆渡车串大景点,但模式很呆——固定站点固定班次;其实车间隔短、车也多,若能招手停会好用得多。

进门或预约最简单的一招:订间青旅床位,人不去睡,办个入住拿个能预约的身份,景点就好约了;实测当天约也不难。

image-20260511013511428

北岸悦本质是青旅,我图便宜订了大床房,结果踩雷:床头疑似漏电——手机充着电,一手拿机一手摸不锈钢床头,能感觉到微微发麻;放下手机又没了。插座嵌在床头板里,估计哪儿搭线了。洗手龙头也为造型牺牲好用度,难拧,还带着整根龙头一起转。

北区

image-20260511012320871

阿那亚算是中高产的高级社区,食堂多,环境在线,但硬把常住居民和观光客搅在一锅。

社区本体观感干净:指示牌少,画面利落,代价是导航费劲——常住的人大概无所谓。

艺术类建筑偏清水混凝土、性冷淡风,裸面不少。

image-20260511012137199

第九食堂早饭扫了一眼,消费不低,比深圳贵;游客价、业主价两套。保洁有 SOP,先喷后擦,不像别处一块油抹布糊弄。食堂几乎没「食堂味」,陈设讲究,自助收盘也不用分筷子,人性化到位。

宠物友好:场所外常有宠物笼暂存,有的还能牵进去,还在建宠物水上乐园,怪不得遛狗的来扎堆。

贩卖机基本平价,没搞景区翻倍那套。

小孩游乐设施多,抱娃来的自然也多。

image-20260511012114629

北教堂不必专程去:人少,框景也没做出来,气质有点神神叨叨,摸不着头脑。

北区卡丁车纯子供向,没成人什么事;有的馆子干脆不开。服务人员密度极高,人力堆出来的基础体验——可想而知日常运营成本得多吓人。

image-20260511012033299

业主区外观像美式、英式红砖公寓那路数:红砖饰面,外挂疏散梯,楼层不高;里头小别墅多是合墅,面积也不大。

店员、服务人员远看可能摸鱼刷手机,但你朝他们走过去,哪怕不说话,也会立刻收手机摆好姿态。当然也偶有装看不见的,那是极少数。

眼下算是阿那亚上升期,上述观感都写在脸上;哪天模式扛不住开始降本增效,服务质量大概率跟着下滑。这套打法至少暂时把房价抬起来了:秦皇岛这种度假地、偏远海边社区,单价两三万,大概是周边盘的四五倍。对比惠州各湾,早就跌穿底线,环境也折腾得不成样子。另一方面社区提供了大量基层岗位,服务人员配置偏冗余,培训和底子还行。

公寓物业费四块、别墅五块九,比深圳不少楼盘还贵;玩法跟惠州类似,买了交给物业代租吃租金。

孤独图书馆

image-20260511012521949

孤独图书馆进门换鞋套,馆内禁拍,但扛不住带娃大军,哭闹不停,跟「图书馆的静」不沾边。

「孤独」更扯淡:淡季里头都接近坐满,外围还有一圈打卡的游客,谈何孤独。

所谓阅读,半数人在刷手机;书也随手堆着,网红属性大于藏书。

想人少可以午饭时段来。馆藏和空间都有限,不必专程来装文艺。

阿那亚礼堂

image-20260511012800677

礼堂里同样禁喧哗、禁拍;座位紧俏,想坐第一排得等人走开。

image-20260511012858053

礼堂框景在线:前方有人玩滑翔伞,海面渔船,岸边牵手的情侣自然嵌进画面。顶上十字架配着巴赫 BWV 548「楔子」前奏曲,神性比北礼堂足。

若有心仪式感十足,在这儿布景求婚一类也未尝不可。

礼堂外风大,一直有保洁盯着周边垃圾,维持「出片」体面。不止礼堂:路边杂物清洁工会顺手捡,社区整体干净,少见卫生死角;搬家、装修中场也能很快收拾,不会半天没人管。路面砖缝油渍之类也有专人抠。

其他

image-20260511014552749

UCCA 沙丘美术馆、阿那亚艺术中心都转了转,略抽象。

还试了赛级无动力帆船:工作人员再三提示当天浪偏大可能全身湿透,仍上了船;结果只湿了一点。船速不慢,得顺着浪主动挪重心,有点意思。

image-20260511014221282

晚上闲逛碰见只黄鼠狼,不怎么怕人。

Summary

北京城里局部很现代化,一出城往六环、保定、河北方向,建设段位明显掉档,跟广东村里不是一个量级。不过华北平原是真平,一望无际,开阔。

Su7 Ultra U南向北

2026-05-06 00:00:00

Foreword

由于保时捷包场,珠海赛道没机会玩,五月前我基本没去过其他赛道。四月初得知五一有U南向北活动,三天连刷北京三个赛道,这种强度很少见,我当场就决定去。

U南向北

5月2日到5月4日,北京三天畅跑三个赛道(凯泽、新金港、老金港),一个赛道一天,费用3000,超值。唯一的问题就一个:怎么把车弄过去。

由于提前知道天津V1赛车场的活动也会在五月举办,所以这趟一次性就能解锁四个赛道,还是非常超值的。

运输

深圳到北京有两千一百多公里,一般就两个办法:自驾2100公里,或者板车托运。自驾根本没这个时间,而且五一前上高速绝对超级堵,所以只能板车托运。

这次大湾区从广深出发一共七辆车,但是其他小伙伴都特别焦虑,巴不得20号就把车发出去,四月十号都没到就联系了各种拖车、酒店、机票,真的有点过于离谱了。然而事情往往变动得更快,有人介绍了一个朋友的拖车公司,价格非常低(2000,普通大概是两千五六的样子),想着是认识的不会被坑,就同意了,24号收车,25号出发,估计28、29就到达了。然而一直到28号,拖车还没出发,负责拖车的朋友满口谎话,一直骗我们很快就走或者各种原因,等到29号单独联系司机才知道车不够,发不了要等。而其他小伙伴本身就是焦虑体质,完全不同意,立马着手备用方案,高价(3000)找了另一个公司的车,包车把我们七辆车拖走并且立即出发。

新拖车司机一路被红包打鸡血,红眼猛冲,五月一号一早就到了北京六环,然而此时我还没意识到发生了什么。

等我五月一号晚上过去拿车,才发现四个气门芯帽被薅走了,腰靠也丢了。其他小伙伴也都有不同程度的小伤,轮毂蹭伤最常见。从老拖车转到新拖车时虽然找人拍了视频,但没想到中间就这一两个小时的时间差,还是被人搞鬼了。

模拟器备赛

三个赛道都练了大概一个月,凯泽开到了52,新金港开到了1分05,老金港开到了1分10。这次练习用了和以前不一样的方法:以前练珠海都是强行记刹车点、记哪里拐、哪里该怎么走;这次直接盲开,全凭速度感觉去刹车和开油。最终抛掉行车线以后,我可以直接凭速度感处理刹车和转弯,不像以前错过一点就直接上墙。这样练下来,总算有了“随便开一个没开过的图也不至于失速上墙”的底气。以前是背板,现在相当于靠下意识处理,容错明显高了。

凯泽赛道

下赛道

image-20260505220801695

凯泽算是比较多Ultra开过,也有Pro车手的开发和讲解,资料比较充足,走线看一看就学会了。

凯泽赛道只能算还行,赛道维护还是可以的,只是场地太小了,缓冲区也很小,稍不注意就是真的上墙了,轮胎缓冲区都很少。凯泽的充电桩只有2个能用,还是上古老桩,30kw,需要刷卡才能充电,我运气比较好在这里充上了。

还好当天实际事故比较少,只有一个人稍微撞了一点墙,惠玮哥刮到了轮胎墙,没有大事故。

image-20260505221305159

实际当天去了以后就发现天气不太行,有点阴沉。我被分到了萌新组,最先上场,熟悉一下和模拟器接近的感觉后立马开始操作。虽然一圈只有六个人,但还是有人挡我,没办法全力push,最终只开到56秒就被催着下场,实际一共就开了七圈。下场以后连汗都没出。然而万万没想到这就是最后一次上场。后续没多久就开始下大雨,赛道全湿,根本没法全力开,而且半热熔轮胎暖不起来,上去开等于送死。

漂移

凯泽的P房是在赛道内圈的,同时漂移场地也在赛道内,也就是说租下凯泽的同时,这个漂移场其实也被租下来了,虽然说不给用,但是实际上你随便玩也没人管,毕竟五一了没人愿意来上班。

image-20260505223236516

刚开始没下雨的时候,就有人直接拿半热熔轮胎开漂,烟超级大,但确实帅,我也坐上去体验了一把。

image-20260505223508735

也有人把握不住,直接轮胎漂到钢丝断了还没发现。

等到中午以后通知下半场没了,直接散场。这时还在下雨,人也走得差不多了,我就喊蔡哥教我漂移,单独练了快一个小时,能漂大概3/4个圆,摸到了一点感觉。在水泥地上起漂对轮胎磨损很小,还不错。场地巨大,基本没有其他车跟我抢,我就一个人玩,真的爽到了。由于我的轮胎还要继续用,后面雨慢慢停了,地面也有点干了,就收了。

新金港赛道

下赛道

image-20260505225921563

新金港也算是比较知名的赛道了,加上Ultra刚出的时候就被各种车手试车和卷圈速,参考视频很多,而且圈速都做到了比较极限。

但是新金港赛道也是比较难的,各种坡度,还有盲弯,都比较考验控车技巧。实际没去过新金港,以为周边是比较完备的,毕竟那么多人都在这里玩,实际一去才发现不是那么回事。新金港基本处于一个特别偏远、废弃的地方,周边路上车都很少了,金港赛道本身旁边不允许建筑,所以连P房都没有,而厕所等等建筑都是那种房车、拖车形式存在的,旁边还有一个漂移场地,但是这次真的没租漂移场地,而且当日也有别的漂移玩家在场上玩,同去的车都不能停漂移场地,现场四十多辆车都停不下,得侵占一半的道路。

新金港本身赛道日1200就可以玩一天,据说去玩的人其实很少,一天可能就十几个人,基本上只要你上场就不会有人影响你,相当于包场。而如果想单买一节,得充值9999,才能按照一节一节的方式去玩。

image-20260505231601027

当看到这个云的时候,不好的预感又来了,果然在我刷完以后没多久,突然就暴雨了,我趁机离开去充电了。第一次只刷到了1分12,没想到最后也没能突破这个圈速,阵雨来得快去得也快,一中午加上车上去扫水基本就把场地晒干了。

当天后续又刷了2节,但是次次都被挡。最后一节摄影师还把运动相机架在我车里了,估计他也没想到,一整圈几乎全是我在吐槽。好几次超车都差点怼到对方车屁股。实际如果我一个人刷,大概能开到1分10;每次被挡,基本都要慢1.x秒,后面就完全不行了。

image-20260505233944223

在金港也试了试所谓的240神教,我只能说我是菜鸡,开不了一点。240刚上去,暖胎圈,假弯过完,回头弯刚往外出,给油我就Spin了,立马意识到是240导致的车尾灵活。于是乎后面开始小心翼翼地开,飞行圈开始,T1转过T2,给油,立马Spin,直接就冲向小山坡了,还好小坡很缓,只是吃点草而已,倒是掀起一阵灰尘,场控对讲机立马问是否有事,答无事,Spin而已。

这一节开到一半我就下场了,240过于灵活,我目前水平完全控制不住,模拟器里也没有相关调节。250的开法下,电控会帮你控制车身姿态,在有可能Spin的情况下,它绝对不允许你强行开油。你只需要走好线、摸好刹车点,能循迹就循迹,其他时间无脑给油就行,车自己会判断能不能给油。所以新手开240绝对不行,250会更保险;240很容易失控甩出去上墙,应该是进阶、有经验的驾驶者才能驾驭。

纽北Ultra专用车

image-20260505234032698

当天贝勒爷也来了金港,刚巧落单被我抓到,收获合照一张。贝勒爷拿到了小米赞助的纽北专用车,日后车主去纽北都可以租借这台车下赛道,而不用官方测试车了。这台车还在现场收集我们车主的签名,后续会直接印在车尾翼上,我也签了,日后去纽北可以自己带自己飞了。

  • 不得不说北京这个季节全是杨柳絮,飘得到处都是,得亏我提前带了个口罩,每天都是一身柳絮,手机屏幕、车窗缝隙都填满了

image-20260505235727140

新金港由于难度比较大,凯泽没出现的车损在今天开始大批量出现了,当天就接近六七辆车出现车损,多次红旗,中止跑圈。

潜力车手

image-20260506002018952

二三十人的住宿被群友的酒店包了,平常七八百的房间450给我们住,还是五一期间,超值,还给了海报、平面轮播,排面拉满。

image-20260506001926361

当天的晚宴,我的圈速基本是萌新组最快,还拿到了潜力车手奖,奖品是个烧水壶。

image-20260506002302152

老金港赛道

老金港,算是尘封已久的赛道了,根本没有Ultra刷过,市面上也没有资料可以参考。模拟器中开车好像还行,但是现实完全不是一回事。

image-20260505235015016

老金港由于严重扰民,所以不允许油车下场,现在改名为新能源汽车赛车场了,只有一些学习赛照的才会在这里开车,平时完全不允许爱好者玩。实际赛道也很偏,周边环境也很小,好在是有一点配套设施的,P房还是有的,周边也有一些充电桩,就是路非常窄。

image-20260506000128337

老金港由于缺少维护,赛道路面已经开裂,而且尘土很多,中间赛道巡游的时候明显能感觉到一直有很多细碎石子被粘起来。

第一节还是萌新组先出车,然而没想到赛道大师卡了个bug,导致我一节跑完没成绩,气死,抢占榜单第一也没戏了。

老金港我练的太多了,而且看了很多油车的开法,大概知道走线和注意点都是啥了,所以我心里很稳。但是没做功课的老司机们反而一个接一个翻车了。

当天上午,只要一节刚上去,没两分钟就必然出现事故,然后就下来等,一等就是四五十分钟,车损基本都是没办法移动那种级别的。就这样,大半天过去了,事故就有五六辆了,都是严重级别的。主办人终于忍不住了,狂喷现场的人,不遵守刹车点,过度push,特别是这个赛道情况明显不够好,而且缓冲区也非常小,很容易出事故。所有人被要求遵守刹车点,不然就终止活动,总算控制住了车损,然而当最后一节自由跑开始,又有人过度push,于是乎自然又有新的车损了。

第二节由于车损和部分人提前离场,萌新组第二组只有3辆车了,我总算是在没有人干扰的情况下刷了个爽的。

image-20260506001223666

如果不是为了遵守刹车点,应该还能再快个1s多,不过就算了。这个场地确实有点危险,几次全力刹车都出现车门储物格里的东西跳出来,差点滚进刹车下面,还好被我踩住了,之前在珠海等地方就算270多刹车也不会出现物品飞出来的情况。

老金港由于换了新胎,抓地力明显比之前强了很多,G值都有1.4了,之前基本只有1.2左右。

Summary

总体来说,北京虽然有3个赛车场,但整体都不算理想:玩车氛围一般,参与人数偏少,车相关产业也比较落后。赛道里相对安全、配套完善的,目前也只有ZIC还可以。

U南向北这次活动,包了三个场,一个场按6w算,三个就是18w,而实际参与人数不到40。虽然拉了赞助,但看规模也不会太多,现场每天抽奖也只是小礼品。活动基本靠活跃群友无偿当志愿者,为爱发电。即使如此,这三天车损还是有十几辆,玩车风险系数确实高,普通人真不一定承受得起,特别是我认识的人里竟然有一半都车损了,确实惨。

而老昆(举办者)非要在赛车场用餐,餐标还是150,高得离谱,吃得巨差。在我看来还不如KFC、MDL,毕竟来跑赛道都比较纯粹,不是来吃喝的。第一天说烤羊腿,我没吃到,就一点自助;第二天总算吃到了,但是一个小瘦羊羔,五六十人分,能吃两片肉就不错了。第三天改烧烤,勉强凑活,舍得给肉了。建议以后取消这种用餐,毫无意义,赛后聚餐我可能还支持一下。

至于说好的每场颁奖,全都没了,活动流程也比较稀碎。除了第一天不迟到,后面天天都迟到。早到完全没意义,现场不给跑,一定要等到10点以后才开始,纯浪费时间。

后续需要重点练两件事:第一是救车,第二是关闭TC后的车辆控制。250再往上想提升,基本就得上240了,尾部姿态必须自己手动控。

AC录像转行车线与轨迹分析

2026-04-06 00:00:00

Foreword

AC模拟器跑完的结果或者录像一直都有,但是缺少具体分析,也没找到类似的分析工具,不如自己写一个,刚好利用Cursor来完全做一个项目,我不写一行代码,仅仅做分析和指导方向,看看是否AI能实现我的全部要求,也能观察转成AI写代码时,我们的输入到底要做到什么程度,这个东西才足够好用或者能够工程化。

ACReplay2AILine

第一个需求其实是比较简单的,分析acreplay的文件格式,然后将其转变成ideal_line.ai的格式。

做之前第一步是询问AI,是否能实现将录像轨迹转成AI行车线,AI表示可以,并且给了几个方案,这里我审过以后,确认了基础实现的技术线路。

image-20260406163337535

如果要我去找AC相关mod的制作信息并且了解清楚行车线和地图相关数据关系,还是比较耗时的,这里AI直接快速解决了问题。

核心结论就是只要非内置的行车线,就可以自行替代,而现在mod级别都不会内置行车线,恰恰方便了行车线的替换逻辑

当然也问了一下是否能根据车型、参数设置、赛道等等直接生成最优行车线,这里由于数据不全,所以AI回答也比较模糊,实际上AC有类似的Mod,但是那种行车线还是有延迟,而且不是很准。

接着就是先做一个最小的MVP,给出最核心的需求,先看一下是否能够实现。

根据回放1.388.acreplay文件转成zhuhai\data\ideal_line.ai格式,并替代

实际上给出这些命令,Cursor就已经完成了核心转换逻辑,实际测试确实替代了老的行车线,但是行车线还存在一些重复的部分并且刹车和油门的提示是错误的,但是轨迹基本都是正确的。

  • 仔细回看,实际上给出来的命令并没有说明要做到刹车油门提示正确,只是说替换一个轨迹而已,所以AI也只是做了这么多内容

基于上面的逻辑,让AI补充提取逻辑

结合记录中的刹车和油门信号,补充到行车线中使用红色或者绿色提示

到这一步,AI直接理解了,并且刹车和油门提示正确了,但是还是有问题,acreplay中飞行圈有时候不一定是第一圈,存在半圈或者开场圈的一点点路径,AI把这部分内容也弄进去,导致一部分轨迹是错误的

根据记录的计时点开始和结束位置,提取轨迹路径

给完这个以后,AI自动理解了计时开始应该从0,结束的时间应该比较长,到这里提取出来的轨迹就是相对完美的了,实际生成的ideal_line.ai已经是我要的轨迹线了

给出更多acreplay文件进行测试,AI自动发现了飞行圈选择的问题,他自己增加了参数选择第n圈,但是实际上我们需要的是最快圈速的那一圈,这一步应该自动选择,而不是还要用户输入

自动识别acreplay中圈速最快的一圈作为提取的轨迹

到这里ACReplay2AILine就完全正常工作了

#Requires -Version 5.1
<#
.SYNOPSIS
  用 acrp 解析录像,将轨迹写入任意赛道的 data\ideal_line.ai(版本 7)。
.DESCRIPTION
  简易用法(acrp.exe 与脚本同目录为默认):
    powershell -ExecutionPolicy Bypass -File .\BuildIdealLineFromReplay.ps1 `
      -Replay "C:\path\lap.acreplay" -TrackFolder "C:\...\content\tracks\zhuhai"

  多车手录像请指定 -DriverName。已导出 JSON 时可省略 -Replay,改用 -JsonPath。

  -TrackFolder: 赛道根目录(其下应有 data\ideal_line.ai,除非用 -IdealLinePath 覆盖)。
  -AcRpPath:  默认 = 脚本目录\acrp.exe

  路径可为绝对路径(如 C:\...\x.acreplay)、相对当前目录、或 ~ 开头(用户主目录);首尾引号会自动去掉。

  轨迹与赛道:ideal_line 只按录像里的世界坐标 x/y/z 重采样,与「目标赛道文件夹」无自动校验,
  请自行保证录像对应该赛道。计时线模式:在起点 currentLap 等于 -Lap 的若干区间中,直接取弧长最长的一段作为一圈(出场短段自然被排除)。

  -Lap 对应录像 JSON 里的 currentLap 整型(通常第 1 圈=0,第 2 圈=1 …),不限于 0/1;第 N 圈飞行一般传 N-1。
  不确定时用 -ShowLapHints 列出每个计时区间起点的 currentLap。
#>
[CmdletBinding()]
param(
    [string]$Replay,
    [string]$TrackFolder,
    [string]$AcRpPath,
    [string]$DriverName,
    [string]$JsonPath,
    [string]$CsvPath,
    [string]$IdealLinePath,
    [int]$Lap = 0,
    [bool]$UseTimingLine = $true,
    [double]$MinSegmentMeters = 50.0,
    [double]$DedupePlanarMin = 0.05,
    [switch]$WhatIf,
    [switch]$KeepTempJson,
    [switch]$ShowLapHints
)

Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'

function Resolve-FsPath([string]$Path) {
    if ($null -eq $Path -or [string]::IsNullOrWhiteSpace($Path)) { return $Path }
    $p = $Path.Trim()
    while ($p.Length -ge 2 -and $p.StartsWith('"') -and $p.EndsWith('"')) {
        $p = $p.Substring(1, $p.Length - 2).Trim()
    }
    if ($p.StartsWith('~')) {
        $rest = $p.Substring(1).TrimStart('\', '/')
        $p = if ($rest) { Join-Path $HOME $rest } else { $HOME }
    }
    return [IO.Path]::GetFullPath($p)
}

function Show-Usage {
    Write-Host @"
用法:
  BuildIdealLineFromReplay.ps1 -Replay <录像.acreplay> -TrackFolder <赛道文件夹> [选项]

必填(二选一):
  -Replay         Assetto Corsa 录像路径
  -TrackFolder     赛道根目录(内含 data\ideal_line.ai)

或已手动用 acrp 导出:
  -JsonPath / -CsvPath  与 -IdealLinePath(或 -TrackFolder)

常用选项:
  -DriverName      多车时指定车手名(传给 acrp --driver-name)
  -AcRpPath        默认: 脚本所在目录\acrp.exe
  -IdealLinePath   默认: <TrackFolder>\data\ideal_line.ai
  -Lap             与录像 currentLap 一致(第 2 圈多为 1,第 3 圈多为 2,依此类推)
  -ShowLapHints    只打印计时线分段与每段起点 currentLap,不写 ideal_line(仅需 JSON)
  -MinSegmentMeters  计时线模式下,若「该 Lap 最长区间」弧长仍小于此值(m)则放弃切段(防数据损坏),默认 50
  -UseTimingLine:`$false  关闭计时线截取
  -WhatIf          只预览不写文件

示例:
  powershell -ExecutionPolicy Bypass -File .\BuildIdealLineFromReplay.ps1 `
    -Replay ".\my.acreplay" -TrackFolder "..\zhuhai"
"@
}

$scriptDir = $null
if ($PSCommandPath) {
    $scriptDir = Split-Path -LiteralPath $PSCommandPath
} elseif ($PSScriptRoot) {
    $scriptDir = $PSScriptRoot
} else {
    try {
        $exePath = [System.Diagnostics.Process]::GetCurrentProcess().MainModule.FileName
        if ($exePath -and (Test-Path -LiteralPath $exePath)) {
            $scriptDir = Split-Path -LiteralPath $exePath
        }
    } catch { }
    if (-not $scriptDir) {
        $a0 = [Environment]::GetCommandLineArgs()[0]
        if ($a0 -and (Test-Path -LiteralPath $a0)) {
            $scriptDir = Split-Path -LiteralPath $a0
        } else {
            $scriptDir = (Get-Location).Path
        }
    }
}
if (-not $scriptDir) { throw 'Cannot resolve script directory (expected exe or .ps1 path).' }
if (-not $AcRpPath -or [string]::IsNullOrWhiteSpace($AcRpPath)) {
    $AcRpPath = Join-Path $scriptDir 'acrp.exe'
} else {
    $AcRpPath = Resolve-FsPath $AcRpPath
}

$useJson = $false
$tempWork = $null

if ($Replay) {
    if (-not $TrackFolder) { throw "使用 -Replay 时必须同时指定 -TrackFolder(赛道根目录)。" }
    if (-not (Test-Path -LiteralPath $AcRpPath)) {
        throw "找不到 acrp.exe: $AcRpPath (可设置 -AcRpPath,或把 acrp.exe 放在脚本同目录)"
    }
    $replayFull = Resolve-FsPath $Replay
    if (-not (Test-Path -LiteralPath $replayFull)) { throw "找不到录像: $replayFull" }

    $trackFull = Resolve-FsPath $TrackFolder
    if (-not (Test-Path -LiteralPath $trackFull -PathType Container)) {
        throw "赛道目录不存在: $trackFull"
    }
    if (-not $IdealLinePath) {
        # "指定在哪里就在哪里":默认不再强制落到 data 子目录。
        $IdealLinePath = Resolve-FsPath (Join-Path $trackFull 'ideal_line.ai')
    } else {
        # 相对路径按当前工作目录解析,不再强制挂到 TrackFolder。
        $IdealLinePath = Resolve-FsPath $IdealLinePath
    }

    $tempWork = Join-Path ([IO.Path]::GetTempPath()) ('ac_ideal_' + [guid]::NewGuid().ToString('N'))
    New-Item -ItemType Directory -Path $tempWork -Force | Out-Null
    $outPrefix = Join-Path $tempWork 'acrp_out'

    $argList = New-Object System.Collections.Generic.List[string]
    [void]$argList.Add('-o')
    [void]$argList.Add($outPrefix)
    if ($DriverName) {
        [void]$argList.Add('--driver-name')
        [void]$argList.Add($DriverName)
    }
    [void]$argList.Add($replayFull)

    Write-Host "运行 acrp: $AcRpPath"
    $proc = Start-Process -FilePath $AcRpPath -ArgumentList $argList.ToArray() -Wait -PassThru -NoNewWindow
    if ($proc.ExitCode -ne 0) {
        if (-not $KeepTempJson) { Remove-Item -LiteralPath $tempWork -Recurse -Force -ErrorAction SilentlyContinue }
        throw "acrp.exe 退出码 $($proc.ExitCode)"
    }

    $jsonFiles = @(Get-ChildItem -LiteralPath $tempWork -Filter *.json -File | Sort-Object LastWriteTime -Descending)
    if ($jsonFiles.Count -eq 0) {
        if (-not $KeepTempJson) { Remove-Item -LiteralPath $tempWork -Recurse -Force -ErrorAction SilentlyContinue }
        throw "acrp 未在临时目录生成 JSON: $tempWork"
    }
    if ($jsonFiles.Count -gt 1 -and -not $DriverName) {
        if (-not $KeepTempJson) { Remove-Item -LiteralPath $tempWork -Recurse -Force -ErrorAction SilentlyContinue }
        throw "生成多个 JSON(多车?),请添加 -DriverName 指定车手。文件: $($jsonFiles.Name -join ', ')"
    }
    $JsonPath = $jsonFiles[0].FullName
    Write-Host "已解析: $JsonPath"
    $useJson = $true
} elseif ($JsonPath -or $CsvPath) {
    if ($JsonPath -and $CsvPath) { throw "请只指定 -JsonPath 或 -CsvPath 其中之一。" }
    if ($JsonPath) {
        $JsonPath = Resolve-FsPath $JsonPath
        if (-not (Test-Path -LiteralPath $JsonPath)) { throw "找不到 JSON: $JsonPath" }
        $useJson = $true
    } else {
        $CsvPath = Resolve-FsPath $CsvPath
        if (-not (Test-Path -LiteralPath $CsvPath)) { throw "找不到 CSV: $CsvPath" }
    }
    if (-not $IdealLinePath) {
        if (-not $TrackFolder) {
            if ($ShowLapHints -and $JsonPath) {
                $IdealLinePath = Join-Path ([IO.Path]::GetTempPath()) '_BuildIdealLine_skip.ai'
            } else {
                throw "使用 -JsonPath/-CsvPath 且未指定 -IdealLinePath 时,需要 -TrackFolder。"
            }
        } else {
            # "指定在哪里就在哪里":默认不再强制落到 data 子目录。
            $IdealLinePath = Resolve-FsPath (Join-Path (Resolve-FsPath $TrackFolder) 'ideal_line.ai')
        }
    } else {
        # 相对路径按当前工作目录解析,不再依赖 TrackFolder 作为基准。
        $IdealLinePath = Resolve-FsPath $IdealLinePath
    }
} else {
    Show-Usage
    throw "请提供 -Replay 与 -TrackFolder,或提供 -JsonPath / -CsvPath。"
}

if (-not $ShowLapHints) {
    if (-not (Test-Path -LiteralPath $IdealLinePath) -and $TrackFolder) {
        $trackBase = Resolve-FsPath $TrackFolder
        $fallbackTemplate = Join-Path $trackBase 'data\ideal_line.ai'
        if (Test-Path -LiteralPath $fallbackTemplate) {
            $outDir = Split-Path -Parent $IdealLinePath
            if ($outDir -and -not (Test-Path -LiteralPath $outDir)) {
                New-Item -ItemType Directory -Path $outDir -Force | Out-Null
            }
            Copy-Item -LiteralPath $fallbackTemplate -Destination $IdealLinePath -Force
            Write-Host "未找到目标 ideal_line.ai,已从模板复制: $fallbackTemplate -> $IdealLinePath"
        }
    }
    if (-not (Test-Path -LiteralPath $IdealLinePath)) {
        if ($tempWork -and -not $KeepTempJson) { Remove-Item -LiteralPath $tempWork -Recurse -Force -ErrorAction SilentlyContinue }
        throw "找不到 ideal_line.ai: $IdealLinePath"
    }
}

# --- 解析轨迹并写 ideal_line ---

function Parse-CsvLine([string]$line) {
    $cells = New-Object System.Collections.Generic.List[string]
    $cur = New-Object System.Text.StringBuilder
    $inQ = $false
    for ($i = 0; $i -lt $line.Length; $i++) {
        $c = $line[$i]
        if ($c -eq '"') {
            $inQ = -not $inQ
        } elseif (($c -eq ',') -and -not $inQ) {
            [void]$cells.Add($cur.ToString())
            [void]$cur.Clear()
        } else {
            [void]$cur.Append($c)
        }
    }
    [void]$cells.Add($cur.ToString())
    return ,$cells.ToArray()
}

function Get-SfCrossingIndices($j) {
    $cross = New-Object System.Collections.Generic.List[int]
    $nF = $j.currentLapTime.Count
    for ($i = 1; $i -lt $nF; $i++) {
        $a = [int]$j.currentLapTime[$i - 1]
        $b = [int]$j.currentLapTime[$i]
        $lapInc = [int]$j.currentLap[$i] - [int]$j.currentLap[$i - 1]
        if (($a - $b -gt 500) -or ($lapInc -gt 0)) {
            $prev = if ($cross.Count -gt 0) { $cross[$cross.Count - 1] } else { -9999 }
            if (($i - $prev) -gt 2) { [void]$cross.Add($i) }
        }
    }
    return $cross
}

function Measure-ArcJson($j, [int]$i0, [int]$i1Exclusive) {
    $s = 0.0
    $px = $null; $py = $null; $pz = $null
    for ($i = $i0; $i -lt $i1Exclusive; $i++) {
        $x = [double]$j.x[$i]; $y = [double]$j.y[$i]; $z = [double]$j.z[$i]
        if ($null -ne $px) {
            $dx = $x - $px; $dy = $y - $py; $dz = $z - $pz
            $s += [Math]::Sqrt($dx * $dx + $dy * $dy + $dz * $dz)
        }
        $px = $x; $py = $y; $pz = $z
    }
    return $s
}

function Select-TimingSegment($j, [int]$Lap, [double]$MinSegmentMeters) {
    $cross = Get-SfCrossingIndices $j
    if ($cross.Count -lt 2) {
        return @{ Start = -1; End = -1; Length = 0.0; Mode = 'no_crossings' }
    }
    $bestLen = -1.0
    $bestA = -1
    $bestB = -1
    for ($k = 0; $k -lt $cross.Count - 1; $k++) {
        $a = $cross[$k]
        $b = $cross[$k + 1]
        if ([int]$j.currentLap[$a] -ne $Lap) { continue }
        $len = Measure-ArcJson $j $a $b
        if ($len -gt $bestLen) {
            $bestLen = $len
            $bestA = $a
            $bestB = $b
        }
    }
    if ($bestA -lt 0) {
        return @{ Start = -1; End = -1; Length = 0.0; Mode = 'no_match' }
    }
    if ($bestLen -lt $MinSegmentMeters) {
        return @{ Start = -1; End = -1; Length = $bestLen; Mode = 'segment_too_short' }
    }
    return @{ Start = $bestA; End = $bestB; Length = $bestLen; Mode = 'longest_for_lap' }
}

if ($ShowLapHints) {
    if (-not $useJson) { throw "-ShowLapHints 仅支持 JSON(-Replay 或 -JsonPath),不支持 CSV。" }
    $jh = Get-Content -LiteralPath $JsonPath -Raw -Encoding UTF8 | ConvertFrom-Json
    if (-not $jh.currentLap -or -not $jh.currentLapTime) {
        throw "JSON 缺少 currentLap 或 currentLapTime,无法分析计时线。"
    }
    $nH = $jh.currentLap.Count
    if ($jh.currentLapTime.Count -ne $nH) { throw "currentLap 与 currentLapTime 长度不一致。" }
    $xc = Get-SfCrossingIndices $jh
    Write-Host "=== ShowLapHints: $JsonPath ==="
    Write-Host "帧数=$nH  检测到计时线交叉索引数=$($xc.Count)"
    Write-Host "(过线后该帧的 currentLap 即「已开始计时的那一圈」编号,通常从 0 递增)"
    for ($ki = 0; $ki -lt $xc.Count; $ki++) {
        $ix = $xc[$ki]
        Write-Host ("  交叉#{0}: frame={1}  currentLap={2}  currentLapTime={3} ms" -f $ki, $ix, [int]$jh.currentLap[$ix], [int]$jh.currentLapTime[$ix])
    }
    for ($ki = 0; $ki -lt $xc.Count - 1; $ki++) {
        $a = $xc[$ki]
        $b = $xc[$ki + 1]
        $alen = Measure-ArcJson $jh $a $b
        $lapAtStart = [int]$jh.currentLap[$a]
        Write-Host ("  区间 frame {0}..{1}: 起点 currentLap={2}  弧长约 {3:F1} m  (同 Lap 多段时脚本取最长段)" -f $a, $b, $lapAtStart, $alen)
    }
    Write-Host "当前默认 -Lap=$Lap;若飞行圈是「第 3 圈」且 AC 从 0 编号,多为 -Lap 2。"
    if ($tempWork -and (Test-Path -LiteralPath $tempWork) -and -not $KeepTempJson) {
        Remove-Item -LiteralPath $tempWork -Recurse -Force -ErrorAction SilentlyContinue
    }
    exit 0
}

try {
$pts = New-Object System.Collections.Generic.List[object]
$hasPedals = $false
$timingMode = 'n/a'
if ($useJson) {
    $j = Get-Content -LiteralPath $JsonPath -Raw -Encoding UTF8 | ConvertFrom-Json
    if (-not $j.x -or -not $j.y -or -not $j.z) { throw "JSON 缺少 x/y/z 数组(请确认为 acrp 导出)。" }
    if (-not $j.currentLap) { throw "JSON 缺少 currentLap 数组。" }
    $nF = $j.x.Count
    if ($j.y.Count -ne $nF -or $j.z.Count -ne $nF -or $j.currentLap.Count -ne $nF) {
        throw "JSON 中 x/y/z/currentLap 长度不一致。"
    }
    if ($j.gas -and $j.brake -and ($j.gas.Count -eq $nF) -and ($j.brake.Count -eq $nF)) {
        $hasPedals = $true
    }

    $iStart = 0
    $iEnd = $nF
    $timingUsed = $false
    if (-not $UseTimingLine) {
        $timingMode = 'timing_disabled'
    } elseif ($j.currentLapTime -and ($j.currentLapTime.Count -eq $nF)) {
        $seg = Select-TimingSegment $j $Lap $MinSegmentMeters
        if ($seg.Start -ge 0) {
            $iStart = $seg.Start
            $iEnd = $seg.End
            $timingUsed = $true
            $timingMode = $seg.Mode
        } elseif ($seg.Mode -eq 'segment_too_short') {
            $timingMode = 'segment_too_short'
            Write-Warning ("计时线切段: 该 Lap 下最长区间仅 {0:F1} m,低于 -MinSegmentMeters ({1} m),已放弃切段。可调小 -MinSegmentMeters 或检查录像。" -f $seg.Length, $MinSegmentMeters)
        } elseif ($seg.Mode -eq 'no_crossings') {
            $timingMode = 'no_crossings'
            Write-Warning "录像中未检测到计时线交叉(currentLapTime/圈数变化),已按整段 -Lap 过滤取点。"
        } else {
            $timingMode = 'lap_filter_pending'
        }
    } else {
        $timingMode = 'no_currentLapTime'
        Write-Warning "JSON 无 currentLapTime 或与帧数不一致,已跳过计时线切段,仅按 -Lap 过滤。"
    }

    for ($i = $iStart; $i -lt $iEnd; $i++) {
        if (-not $timingUsed) {
            if ([int]$j.currentLap[$i] -ne $Lap) { continue }
        }
        $g = if ($hasPedals) { [int]$j.gas[$i] } else { 0 }
        $bk = if ($hasPedals) { [int]$j.brake[$i] } else { 0 }
        if ($g -lt 0) { $g = 0 } elseif ($g -gt 255) { $g = 255 }
        if ($bk -lt 0) { $bk = 0 } elseif ($bk -gt 255) { $bk = 255 }
        [void]$pts.Add([pscustomobject]@{
                X = [float][double]$j.x[$i]
                Y = [float][double]$j.y[$i]
                Z = [float][double]$j.z[$i]
                G = $g
                Bk = $bk
            })
    }
    if ($UseTimingLine -and -not $timingUsed) {
        if ($timingMode -eq 'lap_filter_pending') { $timingMode = 'no_segment_for_lap' }
        Write-Warning "未找到起点 currentLap=$Lap 的计时区间(或交叉点不足),已回退为整段 Lap 过滤。可运行 -ShowLapHints 查看每段起点应对的 -Lap,或 -UseTimingLine:`$false。"
    }
} else {
    $timingMode = 'csv'
    $hdr = Get-Content -LiteralPath $CsvPath -TotalCount 1 -Encoding UTF8
    $names = Parse-CsvLine $hdr
    $ixX = [array]::IndexOf($names, 'position.x')
    $ixY = [array]::IndexOf($names, 'position.y')
    $ixZ = [array]::IndexOf($names, 'position.z')
    $ixLap = [array]::IndexOf($names, 'currentLap')
    $ixGas = [array]::IndexOf($names, 'gas')
    $ixBrake = [array]::IndexOf($names, 'brake')
    if ($ixX -lt 0 -or $ixY -lt 0 -or $ixZ -lt 0) { throw "CSV 缺少 position.x/y/z 列,请确认由 acreplay-parser 导出。" }
    if ($ixLap -lt 0) { throw "CSV 缺少 currentLap 列。" }
    if ($ixGas -ge 0 -and $ixBrake -ge 0) { $hasPedals = $true }

    $reader = [IO.StreamReader]::new($CsvPath, [Text.Encoding]::UTF8, $true)
    try {
        [void]$reader.ReadLine()
        while ($null -ne ($line = $reader.ReadLine())) {
            if ([string]::IsNullOrWhiteSpace($line)) { continue }
            $c = Parse-CsvLine $line
            if ($c.Count -le [Math]::Max($ixX, [Math]::Max($ixY, [Math]::Max($ixZ, $ixLap)))) { continue }
            $lapVal = 0
            [void][int]::TryParse($c[$ixLap].Trim(), [ref]$lapVal)
            if ($lapVal -ne $Lap) { continue }
            $x = [double]::Parse($c[$ixX].Trim(), [Globalization.CultureInfo]::InvariantCulture)
            $y = [double]::Parse($c[$ixY].Trim(), [Globalization.CultureInfo]::InvariantCulture)
            $z = [double]::Parse($c[$ixZ].Trim(), [Globalization.CultureInfo]::InvariantCulture)
            $g = 0; $bk = 0
            if ($hasPedals) {
                [void][int]::TryParse($c[$ixGas].Trim(), [ref]$g)
                [void][int]::TryParse($c[$ixBrake].Trim(), [ref]$bk)
            }
            if ($g -lt 0) { $g = 0 } elseif ($g -gt 255) { $g = 255 }
            if ($bk -lt 0) { $bk = 0 } elseif ($bk -gt 255) { $bk = 255 }
            [void]$pts.Add([pscustomobject]@{ X = [float]$x; Y = [float]$y; Z = [float]$z; G = $g; Bk = $bk })
        }
    } finally { $reader.Close() }
}

if ($DedupePlanarMin -gt 0 -and $pts.Count -gt 2) {
    $dd = New-Object System.Collections.Generic.List[object]
    [void]$dd.Add($pts[0])
    for ($di = 1; $di -lt $pts.Count; $di++) {
        $a = $dd[$dd.Count - 1]
        $b = $pts[$di]
        $dh = [Math]::Sqrt([double](($b.X - $a.X) * ($b.X - $a.X) + ($b.Z - $a.Z) * ($b.Z - $a.Z)))
        if ($dh -ge $DedupePlanarMin) { [void]$dd.Add($b) }
    }
    $pts = $dd
}

if ($pts.Count -lt 200) { throw "该圈采样点过少 ($($pts.Count)),请检查 -DriverName / -Lap / -UseTimingLine。" }

$clean = New-Object System.Collections.Generic.List[object]
[void]$clean.Add($pts[0])
for ($i = 1; $i -lt $pts.Count; $i++) {
    $a = $clean[$clean.Count - 1]
    $b = $pts[$i]
    $d = [Math]::Sqrt([double](($b.X - $a.X) * ($b.X - $a.X) + ($b.Z - $a.Z) * ($b.Z - $a.Z)))
    if ($d -lt 80.0) { [void]$clean.Add($b) }
}
$pts = $clean
if ($pts.Count -lt 200) { throw "过滤跳变后点数不足 ($($pts.Count))。" }

$segLen = New-Object double[] ($pts.Count)
$cum = New-Object double[] ($pts.Count)
$cum[0] = 0.0
for ($i = 1; $i -lt $pts.Count; $i++) {
    $dx = [double]$pts[$i].X - [double]$pts[$i - 1].X
    $dy = [double]$pts[$i].Y - [double]$pts[$i - 1].Y
    $dz = [double]$pts[$i].Z - [double]$pts[$i - 1].Z
    $segLen[$i] = [Math]::Sqrt($dx * $dx + $dy * $dy + $dz * $dz)
    $cum[$i] = $cum[$i - 1] + $segLen[$i]
}
$replayTotal = $cum[$pts.Count - 1]
if ($replayTotal -lt 100.0) { throw "该圈弧长异常短 ($replayTotal m),请换 -Lap 或检查录像。" }

function Get-PointAtDistance([object[]]$p, [double[]]$c, [double]$dist) {
    if ($dist -le 0) { return $p[0] }
    $max = $c[$p.Length - 1]
    if ($dist -ge $max) { return $p[$p.Length - 1] }
    $lo = 0
    $hi = $p.Length - 1
    while ($hi - $lo -gt 1) {
        $mid = [int](($lo + $hi) / 2)
        if ($c[$mid] -le $dist) { $lo = $mid } else { $hi = $mid }
    }
    $i = $lo
    $t = if (($c[$i + 1] - $c[$i]) -gt 1e-6) { ($dist - $c[$i]) / ($c[$i + 1] - $c[$i]) } else { 0.0 }
    $ax = [double]$p[$i].X; $ay = [double]$p[$i].Y; $az = [double]$p[$i].Z
    $bx = [double]$p[$i + 1].X; $by = [double]$p[$i + 1].Y; $bz = [double]$p[$i + 1].Z
    return [pscustomobject]@{
        X = [float]($ax + $t * ($bx - $ax))
        Y = [float]($ay + $t * ($by - $ay))
        Z = [float]($az + $t * ($bz - $az))
    }
}

function Get-Pedal01AtDistance([object[]]$p, [double[]]$c, [double]$dist, [bool]$pickGas) {
    if ($dist -le 0) {
        $v = if ($pickGas) { [double]$p[0].G } else { [double]$p[0].Bk }
        return [float]($v / 255.0)
    }
    $max = $c[$p.Length - 1]
    if ($dist -ge $max) {
        $v = if ($pickGas) { [double]$p[$p.Length - 1].G } else { [double]$p[$p.Length - 1].Bk }
        return [float]($v / 255.0)
    }
    $lo = 0
    $hi = $p.Length - 1
    while ($hi - $lo -gt 1) {
        $mid = [int](($lo + $hi) / 2)
        if ($c[$mid] -le $dist) { $lo = $mid } else { $hi = $mid }
    }
    $i = $lo
    $tt = if (($c[$i + 1] - $c[$i]) -gt 1e-6) { ($dist - $c[$i]) / ($c[$i + 1] - $c[$i]) } else { 0.0 }
    $va = if ($pickGas) { [double]$p[$i].G } else { [double]$p[$i].Bk }
    $vb = if ($pickGas) { [double]$p[$i + 1].G } else { [double]$p[$i + 1].Bk }
    return [float](($va + $tt * ($vb - $va)) / 255.0)
}

$bytes = [IO.File]::ReadAllBytes($IdealLinePath)
$ver = [BitConverter]::ToInt32($bytes, 0)
if ($ver -ne 7) { throw "ideal_line 版本为 $ver,本脚本仅按版本 7 处理。" }
$n = [BitConverter]::ToInt32($bytes, 4)
if ($n -lt 10) { throw "点数异常: $n" }

$oldLens = New-Object float[] $n
for ($i = 0; $i -lt $n; $i++) {
    $o = 16 + $i * 20 + 12
    $oldLens[$i] = [BitConverter]::ToSingle($bytes, $o)
}
$oldMax = [double]$oldLens[$n - 1]
if ($oldMax -lt 1.0) { throw "原线累计长度异常。" }

if ($WhatIf) {
    $pedalNote = if ($hasPedals) { "写入 Gas/Brake" } else { "无油门刹车数据,不改颜色" }
    Write-Host "WhatIf: $IdealLinePath | $n 点 | Lap=$Lap | timing=$timingMode | 采样 $($pts.Count) | 弧长 $replayTotal m | 原线长 $oldMax m | $pedalNote"
    exit 0
}

$bak = $IdealLinePath + ".bak_" + (Get-Date -Format "yyyyMMdd_HHmmss")
Copy-Item -LiteralPath $IdealLinePath -Destination $bak -Force
Write-Host "已备份: $bak"

$newLens = New-Object float[] $n
for ($i = 0; $i -lt $n; $i++) {
    $frac = [double]$oldLens[$i] / $oldMax
    $d = $frac * $replayTotal
    $newLens[$i] = [float]$d
    $q = Get-PointAtDistance $pts $cum $d
    $o = 16 + $i * 20
    [Array]::Copy([BitConverter]::GetBytes($q.X), 0, $bytes, $o, 4)
    [Array]::Copy([BitConverter]::GetBytes($q.Y), 0, $bytes, $o + 4, 4)
    [Array]::Copy([BitConverter]::GetBytes($q.Z), 0, $bytes, $o + 8, 4)
    [Array]::Copy([BitConverter]::GetBytes($newLens[$i]), 0, $bytes, $o + 12, 4)
}

$PointExtraStride = 72
$nEx = [BitConverter]::ToInt32($bytes, 16 + 20 * $n)
$extraStart = 16 + 20 * $n + 4
if ($hasPedals -and ($nEx -eq $n) -and (($bytes.Length - $extraStart) -ge ($n * $PointExtraStride))) {
    $ptArr = $pts.ToArray()
    for ($i = 0; $i -lt $n; $i++) {
        $d = [double]$newLens[$i]
        $gas01 = Get-Pedal01AtDistance $ptArr $cum $d $true
        $brake01 = Get-Pedal01AtDistance $ptArr $cum $d $false
        $eo = $extraStart + $i * $PointExtraStride
        [Array]::Copy([BitConverter]::GetBytes($gas01), 0, $bytes, $eo + 4, 4)
        [Array]::Copy([BitConverter]::GetBytes($brake01), 0, $bytes, $eo + 8, 4)
    }
    Write-Host "已更新 PointsExtra 的 Gas/Brake。"
} elseif ($hasPedals) {
    Write-Warning "PointsExtra 与点数不匹配,已跳过颜色写入。"
}

[IO.File]::WriteAllBytes($IdealLinePath, $bytes)
Write-Host "完成: $IdealLinePath"
}
finally {
    if ($tempWork -and (Test-Path -LiteralPath $tempWork) -and -not $KeepTempJson) {
        Remove-Item -LiteralPath $tempWork -Recurse -Force -ErrorAction SilentlyContinue
    }
}

到这里ACReplay2AILine基本完成,但是还是存在一些问题,他只能修改ailine,并不能凭空生成,所以需要先把原本赛道的ailine拿到,才能改,然后其中未修改的值,可能有一部分是不对的或者不匹配的。

image-20260406174421500

ACReplay Analysis

实现了上面的回放转行车线,我就思考是否可以把轨迹内容输出,并且和地图匹配到一起,这样就能单独看到自己在每个弯的刹车点、速度和开油点和对应的速度了,这样做分析完就可以模板化操作了。

实际想得还是太简单了,游戏内mod的数据给的太少了,赛道边界信息啥的都没给,而且现实中的T1-T14是人为定义的,实际上游戏内根本没有这个弯道定义,游戏内只是对赛道进行了3个segment的分段,要把现实和游戏的轨迹匹配上就有点困难了。

然后再说一个,游戏内是没有经纬度信息的,使用的xyz坐标系统,而现实T1-T14都不是,他们的经纬度信息缺少,这让匹配就更难了。

开始的几次尝试基本都失败了,回放轨迹和赛道匹配不上,比例大小都不正确,其实是缺少了赛道的宽度具体赛道边界曲线信息。

基于此放弃了赛道匹配,直接画轨迹,这个部分没问题,但是弯道匹配还是有误,T1-T14怎么都对不上,反复调整代码也不行,这个流程估计耗时两三个小时,最后放弃了。

直接使用轨迹和刹车点、开油点的逻辑,把每次操作的位置和此时时速都标识出来

image-20260406175033265

然后就得到了这样一张图,我称为 Action 图(操作图),可以清晰看到每个位置大概以多少速度刹车、大概在什么位置开油。

  • 不过还是有点小问题,中间不给油或者保持油门的细节没有

这里比较麻烦的点是油门和刹车的判断,刹多少算刹车,持续多久算一次?同理油门,一开始沟通时没有给到这部分信息,让AI自主判断,但是结果是比较差的,出现各种奇怪情况,比如油门默认高时,不算油门上升,刹车默认高也不算刹车,上升比例要求的太多了,导致细节反馈不出来

反馈给AI以后再次生成,依然错误,甚至越改越偏。但是由于基础代码被改了,没有 commit,导致最后一错到底,无法纠正回来,只好放弃掉这部分 AI,重新梳理逻辑,再重新对话写代码。

反复调试,增加约束调节以后,总算得到了一个正确的图,并且增加用例测试,得到的结果都还行

#Requires -Version 5.1
<#
.SYNOPSIS
  Generic replay lap analyzer: detect brake/throttle onsets and render trajectory markers.
  If replay/corners data files are missing, they are auto-generated from the provided replay.
#>
param(
    [string]$JsonPath = '',
    [string]$TrackFolder = '',
    [string]$CornersJson = '',
    [string]$ReplayPath = '',
    [string]$AcRpPath = '',
    [string]$DriverName = '',
    [string]$OutputPath = '',
    [int]$Lap = 0,
    [bool]$AutoFastestLap = $true,
    [double]$MinSegmentMeters = 50.0,
    [int]$ImageWidth = 1800,
    [int]$ImageHeight = 1350,
    [double]$InnerMarginPercent = 5.0,
    [float]$FontSizeTitle = 20.0,
    [float]$FontSizeMarker = 14.0,
    [double]$BrakeMinSeconds = 0.3,
    [double]$ThrottleMinSeconds = 0.5,
    [int]$BrakePedalThreshold = 25,
    [int]$GasPedalThreshold = 180,
    [double]$GasReapplyMinSeconds = 0.06,
    [int]$GasReapplyThreshold = 60,
    [int]$GasReapplyDelta = 20,
    [int]$GasReapplyBrakeMax = 20,
    [bool]$AllowOverlapThrottleBetweenBrakes = $true,
    [double]$SectorExpandMeters = 20.0,
    [switch]$DebugEventTrace,
    [string]$DebugOutputPath = '',
    [switch]$HideCornerCenterLabel,
    [switch]$NoVerticalFlip,
    [switch]$FlipWorldZ
)

$ErrorActionPreference = 'Stop'
# PS2EXE 嵌入执行时 $PSScriptRoot / $PSCommandPath 可能为空;
# 优先使用进程主模块路径,确保在“当前目录不等于exe目录”时也能稳定定位工具目录。
$toolDir = $null
if ($PSCommandPath) {
    $toolDir = Split-Path -LiteralPath $PSCommandPath
} elseif ($PSScriptRoot) {
    $toolDir = $PSScriptRoot
} else {
    try {
        $exePath = [System.Diagnostics.Process]::GetCurrentProcess().MainModule.FileName
        if ($exePath -and (Test-Path -LiteralPath $exePath)) {
            $toolDir = Split-Path -LiteralPath $exePath
        }
    } catch { }
    if (-not $toolDir) {
        $a0 = [Environment]::GetCommandLineArgs()[0]
        if ($a0 -and (Test-Path -LiteralPath $a0)) {
            $toolDir = Split-Path -LiteralPath $a0
        } else {
            $toolDir = (Get-Location).Path
        }
    }
}
if (-not $toolDir) { throw 'Cannot resolve tool directory (expected exe or .ps1 path).' }
if (-not $TrackFolder) { $TrackFolder = Join-Path (Split-Path $toolDir -Parent) 'zhuhai' }
Add-Type -AssemblyName System.Drawing

$capPath = Join-Path $toolDir 'draw_trajectory_captions.json'
$cap = [pscustomobject]@{ sf = 'S/F'; titlePrefix = 'Replay Lap Analysis'; legend = 'Blue=track Orange=S/F Red=brake Green=throttle' }
if (Test-Path -LiteralPath $capPath) {
    $cj = Get-Content -LiteralPath $capPath -Raw -Encoding UTF8 | ConvertFrom-Json
    if ($cj.sf) { $cap.sf = [string]$cj.sf }
    if ($cj.titlePrefix) { $cap.titlePrefix = [string]$cj.titlePrefix }
    if ($cj.legend) { $cap.legend = [string]$cj.legend }
}

function Resolve-FsPath([string]$Path) {
    if ([string]::IsNullOrWhiteSpace($Path)) { return $Path }
    $p = $Path.Trim()
    while ($p.Length -ge 2 -and $p.StartsWith('"') -and $p.EndsWith('"')) {
        $p = $p.Substring(1, $p.Length - 2).Trim()
    }
    if ($p.StartsWith('~')) {
        $rest = $p.Substring(1).TrimStart('\', '/')
        $p = if ($rest) { Join-Path $HOME $rest } else { $HOME }
    }
    return [IO.Path]::GetFullPath($p)
}

function Get-FileStem([string]$pathOrName, [string]$fallback) {
    if ([string]::IsNullOrWhiteSpace($pathOrName)) { return $fallback }
    $nm = [IO.Path]::GetFileNameWithoutExtension($pathOrName)
    if ([string]::IsNullOrWhiteSpace($nm)) { return $fallback }
    return $nm
}

function Clamp-Int([int]$v, [int]$lo, [int]$hi) {
    if ($v -lt $lo) { return $lo }
    if ($v -gt $hi) { return $hi }
    return $v
}

function Get-SfCrossingIndices($j) {
    $cross = New-Object System.Collections.Generic.List[int]
    if (-not $j.currentLapTime -or ($j.currentLapTime.Count -ne $j.x.Count)) { return $cross }
    for ($i = 1; $i -lt $j.currentLapTime.Count; $i++) {
        $a = [int]$j.currentLapTime[$i - 1]; $b = [int]$j.currentLapTime[$i]
        $lapInc = [int]$j.currentLap[$i] - [int]$j.currentLap[$i - 1]
        if (($a - $b -gt 500) -or ($lapInc -gt 0)) {
            $prev = if ($cross.Count -gt 0) { $cross[$cross.Count - 1] } else { -9999 }
            if (($i - $prev) -gt 2) { [void]$cross.Add($i) }
        }
    }
    return $cross
}

function Measure-ArcJson($j, [int]$i0, [int]$i1Exclusive) {
    $s = 0.0; $px = $null; $py = $null; $pz = $null
    for ($i = $i0; $i -lt $i1Exclusive; $i++) {
        $x = [double]$j.x[$i]; $y = [double]$j.y[$i]; $z = [double]$j.z[$i]
        if ($null -ne $px) {
            $dx = $x - $px; $dy = $y - $py; $dz = $z - $pz
            $s += [Math]::Sqrt($dx * $dx + $dy * $dy + $dz * $dz)
        }
        $px = $x; $py = $y; $pz = $z
    }
    return $s
}

function Select-TimingSegment($j, [int]$LapVal, [double]$MinSeg) {
    $cross = Get-SfCrossingIndices $j
    if ($cross.Count -lt 2) { return @{ Start = -1; End = -1; Length = 0.0; Mode = 'no_crossings' } }
    $bestLen = -1.0; $bestA = -1; $bestB = -1
    for ($k = 0; $k -lt $cross.Count - 1; $k++) {
        $a = $cross[$k]; $b = $cross[$k + 1]
        if ([int]$j.currentLap[$a] -ne $LapVal) { continue }
        $len = Measure-ArcJson $j $a $b
        if ($len -gt $bestLen) { $bestLen = $len; $bestA = $a; $bestB = $b }
    }
    if ($bestA -lt 0) { return @{ Start = -1; End = -1; Length = 0.0; Mode = 'no_match' } }
    if ($bestLen -lt $MinSeg) { return @{ Start = -1; End = -1; Length = $bestLen; Mode = 'segment_too_short' } }
    return @{ Start = $bestA; End = $bestB; Length = $bestLen; Mode = 'ok' }
}

function Select-FastestTimingSegment($j, [double]$MinSeg) {
    $cross = Get-SfCrossingIndices $j
    if ($cross.Count -lt 2) { return @{ Start = -1; End = -1; Length = 0.0; Mode = 'no_crossings'; Lap = -1; TimeMs = -1 } }

    $best = $null
    for ($k = 0; $k -lt $cross.Count - 1; $k++) {
        $a = $cross[$k]; $b = $cross[$k + 1]
        $lapVal = [int]$j.currentLap[$a]
        $len = Measure-ArcJson $j $a $b
        if ($len -lt $MinSeg) { continue }

        $timeMs = -1
        if ($j.PSObject.Properties.Name -contains 'currentLapTime') {
            $ti = [int]$j.currentLapTime[[Math]::Max($a, $b - 1)]
            if ($ti -gt 0) { $timeMs = $ti }
        }
        if ($timeMs -le 0) {
            $dt = Get-FrameDtSeconds $j
            $timeMs = [int][Math]::Round(($b - $a) * $dt * 1000.0)
        }

        $cand = @{
            Start = $a
            End = $b
            Length = $len
            Mode = 'ok'
            Lap = $lapVal
            TimeMs = $timeMs
        }
        if ($null -eq $best -or $cand.TimeMs -lt $best.TimeMs) {
            $best = $cand
        }
    }

    if ($null -eq $best) { return @{ Start = -1; End = -1; Length = 0.0; Mode = 'no_valid_segment'; Lap = -1; TimeMs = -1 } }
    return $best
}

function Get-SpeedKmh($j, [int]$fi) {
    $vx = [double]$j.velocityX[$fi]; $vy = [double]$j.velocityY[$fi]; $vz = [double]$j.velocityZ[$fi]
    return [Math]::Sqrt($vx * $vx + $vy * $vy + $vz * $vz) * 3.6
}

function Get-FrameDtSeconds($j) {
    if ($j.PSObject.Properties.Name -contains 'recordingInterval') {
        $ri = [double]$j.recordingInterval
        if ($ri -gt 0 -and $ri -le 100.0) { return $ri / 1000.0 }
        if ($ri -gt 100.0) { return 1.0 / $ri }
    }
    return (1.0 / 60.0)
}

function Get-BoundariesFromSegmentEnds([double[]]$ends) {
    if ($ends.Count -ne 14) { throw 'segmentEndFraction must have 14 elements, last=1.0' }
    if ([Math]::Abs($ends[13] - 1.0) -gt 0.001) { throw 'segmentEndFraction[13] must be 1.0' }
    $b = New-Object double[] 15
    $b[0] = 0.0
    for ($i = 0; $i -lt 14; $i++) { $b[$i + 1] = $ends[$i] }
    return $b
}

function Find-KRangeForArc([double[]]$sArr, [double]$lapLen, [double]$f0, [double]$f1, [int]$m) {
    $s0 = [Math]::Max(0.0, $f0 * $lapLen)
    $s1 = [Math]::Min($lapLen, $f1 * $lapLen)
    $k0 = 0
    for ($k = 0; $k -lt $m; $k++) {
        if ($sArr[$k] -ge $s0) { $k0 = $k; break }
    }
    $k1 = $m - 1
    for ($k = $m - 1; $k -ge 0; $k--) {
        if ($sArr[$k] -le $s1) { $k1 = $k; break }
    }
    if ($k1 -lt $k0) { $k1 = $k0 }
    return $k0, $k1
}

function Find-KClosestToS([double[]]$sArr, [double]$targetS, [int]$k0, [int]$k1) {
    $best = $k0
    $bd = [Math]::Abs($sArr[$k0] - $targetS)
    for ($k = $k0; $k -le $k1; $k++) {
        $d = [Math]::Abs($sArr[$k] - $targetS)
        if ($d -lt $bd) { $bd = $d; $best = $k }
    }
    return $best
}

function Find-FirstSustainedAbove([int[]]$vals, [int]$k0, [int]$k1, [int]$thr, [int]$minFrames) {
    if ($minFrames -lt 1) { $minFrames = 1 }
    for ($start = $k0; $start -le $k1; $start++) {
        if ($vals[$start] -lt $thr) { continue }
        $ok = $true
        for ($i = 0; $i -lt $minFrames; $i++) {
            $kk = $start + $i
            if ($kk -gt $k1) { $ok = $false; break }
            if ($vals[$kk] -lt $thr) { $ok = $false; break }
        }
        if ($ok) { return $start }
    }
    return -1
}

function Find-FirstRisingSustainedAbove([int[]]$vals, [int]$k0, [int]$k1, [int]$thr, [int]$minFrames) {
    if ($minFrames -lt 1) { $minFrames = 1 }
    $st0 = [Math]::Max(1, $k0)
    for ($start = $st0; $start -le $k1; $start++) {
        # Rising edge: previous frame below threshold, current frame reaches threshold.
        if ($vals[$start - 1] -ge $thr) { continue }
        if ($vals[$start] -lt $thr) { continue }
        $ok = $true
        for ($i = 0; $i -lt $minFrames; $i++) {
            $kk = $start + $i
            if ($kk -gt $k1) { $ok = $false; break }
            if ($vals[$kk] -lt $thr) { $ok = $false; break }
        }
        if ($ok) { return $start }
    }
    return -1
}

function Find-FirstGasReapply([int[]]$gasVals, [int[]]$brkVals, [int]$k0, [int]$k1, [int]$minGas, [int]$minDelta, [int]$brakeMax, [int]$minFrames) {
    if ($minFrames -lt 1) { $minFrames = 1 }
    $st0 = [Math]::Max(1, $k0)
    for ($start = $st0; $start -le $k1; $start++) {
        if ($brkVals[$start] -gt $brakeMax) { continue }
        if ($gasVals[$start] -lt $minGas) { continue }
        if (($gasVals[$start] - $gasVals[$start - 1]) -lt $minDelta) { continue }
        $ok = $true
        for ($i = 0; $i -lt $minFrames; $i++) {
            $kk = $start + $i
            if ($kk -gt $k1) { $ok = $false; break }
            if ($gasVals[$kk] -lt $minGas) { $ok = $false; break }
            if ($brkVals[$kk] -gt $brakeMax) { $ok = $false; break }
        }
        if ($ok) { return $start }
    }
    return -1
}

function Find-FirstGasReapplyOverlap([int[]]$gasVals, [int]$k0, [int]$k1, [int]$minGas, [int]$minDelta, [int]$minFrames) {
    if ($minFrames -lt 1) { $minFrames = 1 }
    $st0 = [Math]::Max(1, $k0)
    for ($start = $st0; $start -le $k1; $start++) {
        if ($gasVals[$start] -lt $minGas) { continue }
        if (($gasVals[$start] - $gasVals[$start - 1]) -lt $minDelta) { continue }
        $ok = $true
        for ($i = 0; $i -lt $minFrames; $i++) {
            $kk = $start + $i
            if ($kk -gt $k1) { $ok = $false; break }
            if ($gasVals[$kk] -lt $minGas) { $ok = $false; break }
        }
        if ($ok) { return $start }
    }
    return -1
}

function Get-ContiguousRunEnd([int[]]$vals, [int]$start, [int]$k1, [int]$thr) {
    $e = $start
    for ($k = $start; $k -le $k1; $k++) {
        if ($vals[$k] -ge $thr) { $e = $k } else { break }
    }
    return $e
}

function Get-LongestRunAbove([int[]]$vals, [int]$k0, [int]$k1, [int]$thr) {
    if ($k1 -lt $k0) { return 0 }
    $best = 0
    $cur = 0
    for ($k = $k0; $k -le $k1; $k++) {
        if ($vals[$k] -ge $thr) {
            $cur++
            if ($cur -gt $best) { $best = $cur }
        } else {
            $cur = 0
        }
    }
    return $best
}

function New-CjkDrawingFont([float]$emSize, [System.Drawing.FontStyle]$style) {
    $unit = [System.Drawing.GraphicsUnit]::Point
    foreach ($n in @('Microsoft YaHei UI', 'Microsoft YaHei', 'SimHei', 'Segoe UI')) {
        try {
            $fam = New-Object System.Drawing.FontFamily $n
            if ($fam.IsStyleAvailable($style)) { return [System.Drawing.Font]::new($fam, $emSize, $style, $unit) }
        } catch { }
    }
    return [System.Drawing.Font]::new('Segoe UI', $emSize, $style, $unit)
}

function Ensure-ReplayJson([string]$TargetJsonPath, [string]$ReplayPathIn, [string]$AcRpPathIn, [string]$DriverNameIn) {
    if (Test-Path -LiteralPath $TargetJsonPath) { return }
    $acrp = if ([string]::IsNullOrWhiteSpace($AcRpPathIn)) { Join-Path $toolDir 'acrp.exe' } else { Resolve-FsPath $AcRpPathIn }
    if (-not (Test-Path -LiteralPath $acrp)) {
        throw "Replay JSON missing and acrp.exe not found: $acrp"
    }

    $replay = $ReplayPathIn
    if ([string]::IsNullOrWhiteSpace($replay)) {
        $rp = @(Get-ChildItem -LiteralPath $toolDir -Filter *.acreplay -File | Sort-Object LastWriteTime -Descending)
        if ($rp.Count -lt 1) { throw "Replay JSON missing and no .acreplay found in $toolDir" }
        $replay = $rp[0].FullName
    } else {
        $replay = Resolve-FsPath $replay
    }
    if (-not (Test-Path -LiteralPath $replay)) { throw "Replay file not found: $replay" }

    $outDir = Split-Path -Parent $TargetJsonPath
    if ($outDir -and -not (Test-Path -LiteralPath $outDir)) {
        New-Item -ItemType Directory -Path $outDir -Force | Out-Null
    }

    $tempWork = Join-Path ([IO.Path]::GetTempPath()) ('ac_lap_' + [guid]::NewGuid().ToString('N'))
    New-Item -ItemType Directory -Path $tempWork -Force | Out-Null
    $outPrefix = Join-Path $tempWork 'acrp_out'
    try {
        $argList = New-Object System.Collections.Generic.List[string]
        [void]$argList.Add('-o')
        [void]$argList.Add($outPrefix)
        if (-not [string]::IsNullOrWhiteSpace($DriverNameIn)) {
            [void]$argList.Add('--driver-name')
            [void]$argList.Add($DriverNameIn)
        }
        [void]$argList.Add($replay)

        Write-Host "Generating replay JSON via acrp: $replay"
        $proc = Start-Process -FilePath $acrp -ArgumentList $argList.ToArray() -Wait -PassThru -NoNewWindow
        if ($proc.ExitCode -ne 0) { throw "acrp.exe exit code $($proc.ExitCode)" }

        $jsonFiles = @(Get-ChildItem -LiteralPath $tempWork -Filter *.json -File | Sort-Object LastWriteTime -Descending)
        if ($jsonFiles.Count -lt 1) { throw "acrp generated no JSON in: $tempWork" }
        if ($jsonFiles.Count -gt 1 -and [string]::IsNullOrWhiteSpace($DriverNameIn)) {
            throw "acrp generated multiple JSON files; pass -DriverName to pick one."
        }
        Copy-Item -LiteralPath $jsonFiles[0].FullName -Destination $TargetJsonPath -Force
        Write-Host "Generated: $TargetJsonPath"
    } finally {
        Remove-Item -LiteralPath $tempWork -Recurse -Force -ErrorAction SilentlyContinue
    }
}

function Build-CornerJsonFromReplay($j, [string]$TargetPath, [int]$LapVal, [double]$MinSegMeters, [double]$DedupMinGapMeters, [bool]$AutoFastestLapVal) {
    $seg = if ($AutoFastestLapVal) { Select-FastestTimingSegment $j $MinSegMeters } else { Select-TimingSegment $j $LapVal $MinSegMeters }
    if ($seg.Mode -ne 'ok' -or $seg.Start -lt 0) { throw "Cannot build corners: timing segment $($seg.Mode)" }
    $iStart = $seg.Start; $iEnd = $seg.End
    $idx = New-Object System.Collections.Generic.List[int]
    for ($i = $iStart; $i -lt $iEnd; $i++) { [void]$idx.Add($i) }
    if ($idx.Count -lt 200) { throw "Cannot build corners: too few frames ($($idx.Count))" }

    $m = $idx.Count
    $s = New-Object double[] $m
    $brk = New-Object int[] $m
    for ($k = 0; $k -lt $m; $k++) {
        $fi = $idx[$k]
        if ($k -gt 0) {
            $pi = $idx[$k - 1]
            $dx = [double]$j.x[$fi] - [double]$j.x[$pi]
            $dy = [double]$j.y[$fi] - [double]$j.y[$pi]
            $dz = [double]$j.z[$fi] - [double]$j.z[$pi]
            $s[$k] = $s[$k - 1] + [Math]::Sqrt($dx * $dx + $dy * $dy + $dz * $dz)
        }
        $brk[$k] = [int]$j.brake[$fi]
    }
    $lapLen = $s[$m - 1]
    if ($lapLen -lt 100.0) { throw "Cannot build corners: lap length abnormal ($lapLen)" }

    $cand = New-Object System.Collections.Generic.List[object]
    for ($k = 1; $k -lt $m; $k++) {
        $prev = $brk[$k - 1]; $cur = $brk[$k]
        $isOnset = ($cur -ge 35 -and $prev -lt 25) -or ($cur -ge 22 -and $prev -lt 12) -or (($cur - $prev) -ge 20 -and $cur -ge 18)
        if ($isOnset) {
            [void]$cand.Add([pscustomobject]@{
                K = $k
                S = $s[$k]
                Fraction = ($s[$k] / $lapLen)
                Score = ($cur + [Math]::Max(0, $cur - $prev))
            })
        }
    }
    if ($cand.Count -lt 14) { throw "Cannot build corners: brake onset candidates <14 ($($cand.Count))" }

    $selected = New-Object System.Collections.Generic.List[object]
    foreach ($c in ($cand | Sort-Object Score -Descending)) {
        if ($selected.Count -ge 14) { break }
        $ok = $true
        foreach ($slt in $selected) {
            $d = [Math]::Abs($c.S - $slt.S)
            $dc = [Math]::Min($d, $lapLen - $d)
            if ($dc -lt $DedupMinGapMeters) { $ok = $false; break }
        }
        if ($ok) { [void]$selected.Add($c) }
    }
    if ($selected.Count -lt 14) {
        foreach ($c in ($cand | Sort-Object Score -Descending)) {
            if ($selected.Count -ge 14) { break }
            $exists = $false
            foreach ($slt in $selected) { if ([int]$slt.K -eq [int]$c.K) { $exists = $true; break } }
            if (-not $exists) { [void]$selected.Add($c) }
        }
    }
    if ($selected.Count -lt 14) { throw "Cannot build corners: selected <14 ($($selected.Count))" }

    $bf = @($selected | Sort-Object Fraction | Select-Object -First 14 | ForEach-Object { [double]$_.Fraction })
    $ends = @()
    for ($i = 0; $i -lt 13; $i++) { $ends += [Math]::Round((($bf[$i] + $bf[$i + 1]) / 2.0), 6) }
    $ends += 1.0

    $b = @(0.0) + $ends
    $center = @()
    for ($i = 0; $i -lt 14; $i++) { $center += [Math]::Round((($b[$i] + $b[$i + 1]) / 2.0), 6) }

    $obj = [ordered]@{
        _comment = "Auto-generated by DrawZhuhaiLapCorners.ps1 from replay brake onsets."
        _comment2 = "segmentEndFraction[13] fixed at 1.0; cornerCenterFraction is sector midpoint."
        segmentEndFraction = $ends
        cornerCenterFraction = $center
    }
    $outDir = Split-Path -Parent $TargetPath
    if ($outDir -and -not (Test-Path -LiteralPath $outDir)) {
        New-Item -ItemType Directory -Path $outDir -Force | Out-Null
    }
    ($obj | ConvertTo-Json -Depth 6) | Set-Content -LiteralPath $TargetPath -Encoding UTF8
    Write-Host "Generated: $TargetPath"
}

$ReplayPath = Resolve-FsPath $ReplayPath
$legacyJson = Join-Path $toolDir 'zhuhai_replay_out_elmagnifico.json'
$legacyCorners = Join-Path $toolDir 'zhuhai_t1_t14_apex_fractions.json'

if ([string]::IsNullOrWhiteSpace($JsonPath)) {
    if (-not [string]::IsNullOrWhiteSpace($ReplayPath)) {
        $rpDir = Split-Path -Parent $ReplayPath
        $rpStem = Get-FileStem $ReplayPath 'replay'
        $JsonPath = Join-Path $rpDir ($rpStem + '_replay.json')
    } elseif (Test-Path -LiteralPath $legacyJson) {
        $JsonPath = $legacyJson
    } else {
        throw "Please provide -ReplayPath or -JsonPath."
    }
}
if ([string]::IsNullOrWhiteSpace($CornersJson)) {
    if (-not [string]::IsNullOrWhiteSpace($ReplayPath)) {
        $rpDir = Split-Path -Parent $ReplayPath
        $rpStem = Get-FileStem $ReplayPath 'replay'
        $CornersJson = Join-Path $rpDir ($rpStem + '_corners.json')
    } elseif (Test-Path -LiteralPath $legacyCorners) {
        $CornersJson = $legacyCorners
    } else {
        $jDir = Split-Path -Parent $JsonPath
        $jStem = Get-FileStem $JsonPath 'replay'
        $CornersJson = Join-Path $jDir ($jStem + '_corners.json')
    }
}
if ([string]::IsNullOrWhiteSpace($OutputPath)) {
    if (-not [string]::IsNullOrWhiteSpace($ReplayPath)) {
        $rpDir = Split-Path -Parent $ReplayPath
        $rpStem = Get-FileStem $ReplayPath 'replay'
        $OutputPath = Join-Path $rpDir ($rpStem + '_brake_throttle_points.png')
    } else {
        $jDir = Split-Path -Parent $JsonPath
        $jStem = Get-FileStem $JsonPath 'replay'
        $OutputPath = Join-Path $jDir ($jStem + '_brake_throttle_points.png')
    }
}
if ([string]::IsNullOrWhiteSpace($DebugOutputPath)) {
    $DebugOutputPath = [IO.Path]::ChangeExtension($OutputPath, '.debug.csv')
}

$JsonPath = Resolve-FsPath $JsonPath
$CornersJson = Resolve-FsPath $CornersJson
$OutputPath = Resolve-FsPath $OutputPath
$DebugOutputPath = Resolve-FsPath $DebugOutputPath
Ensure-ReplayJson $JsonPath $ReplayPath $AcRpPath $DriverName

$j = Get-Content -LiteralPath $JsonPath -Raw -Encoding UTF8 | ConvertFrom-Json
if (-not (Test-Path -LiteralPath $CornersJson)) {
    Build-CornerJsonFromReplay $j $CornersJson $Lap $MinSegmentMeters 28.0 $AutoFastestLap
}

$apexObj = Get-Content -LiteralPath $CornersJson -Raw -Encoding UTF8 | ConvertFrom-Json
if (-not $apexObj.segmentEndFraction) { throw 'CornersJson needs segmentEndFraction[14] ending with 1.0' }
$se = @([double[]]@($apexObj.segmentEndFraction))
$boundaries = Get-BoundariesFromSegmentEnds $se
$cornerCenter = $null
if ($apexObj.cornerCenterFraction) {
    $cornerCenter = @([double[]]@($apexObj.cornerCenterFraction))
    if ($cornerCenter.Count -ne 14) { throw 'cornerCenterFraction must have 14 elements if set' }
}

$nF = $j.x.Count
if ($j.velocityX.Count -ne $nF) { throw 'JSON needs velocityX/Y/Z same length as x.' }

$dt = Get-FrameDtSeconds $j
$brkFrames = [int][math]::Ceiling($BrakeMinSeconds / $dt)
$gasFrames = [int][math]::Ceiling($ThrottleMinSeconds / $dt)
$gasReapplyFrames = [int][math]::Ceiling($GasReapplyMinSeconds / $dt)
Write-Host "Frame dt=${dt}s  brake>=${BrakeMinSeconds}s -> ${brkFrames} frames  throttle>=${ThrottleMinSeconds}s -> ${gasFrames} frames"

$seg = if ($AutoFastestLap) { Select-FastestTimingSegment $j $MinSegmentMeters } else { Select-TimingSegment $j $Lap $MinSegmentMeters }
$iStart = 0; $iEnd = $nF; $timingUsed = $false
if ($seg.Mode -eq 'ok' -and $seg.Start -ge 0) {
    $iStart = $seg.Start; $iEnd = $seg.End; $timingUsed = $true
    if ($AutoFastestLap) {
        $Lap = [int]$seg.Lap
        Write-Host "Timing (fastest lap): lap=$Lap time_ms=$($seg.TimeMs) frames $($seg.Start)..$($seg.End) length_m=$([math]::Round($seg.Length,1))"
    } else {
        Write-Host "Timing: frames $($seg.Start)..$($seg.End) length_m=$([math]::Round($seg.Length,1))"
    }
} else {
    Write-Warning "Timing: $($seg.Mode)"
}

$idx = New-Object System.Collections.Generic.List[int]
for ($i = $iStart; $i -lt $iEnd; $i++) {
    if (-not $timingUsed) {
        if ([int]$j.currentLap[$i] -ne $Lap) { continue }
    }
    [void]$idx.Add($i)
}
if ($idx.Count -lt 200) { throw "Too few frames: $($idx.Count)" }

$m = $idx.Count
$s = New-Object double[] $m
$sp = New-Object double[] $m
$brk = New-Object int[] $m
$gas = New-Object int[] $m
$xs = New-Object double[] $m
$zs = New-Object double[] $m
for ($k = 0; $k -lt $m; $k++) {
    $fi = $idx[$k]
    $xs[$k] = [double]$j.x[$fi]; $zs[$k] = [double]$j.z[$fi]
    if ($k -gt 0) {
        $pi = $idx[$k - 1]
        $dx = [double]$j.x[$fi] - [double]$j.x[$pi]
        $dy = [double]$j.y[$fi] - [double]$j.y[$pi]
        $dz = [double]$j.z[$fi] - [double]$j.z[$pi]
        $s[$k] = $s[$k - 1] + [Math]::Sqrt($dx * $dx + $dy * $dy + $dz * $dz)
    }
    $sp[$k] = Get-SpeedKmh $j $fi
    $brk[$k] = [int]$j.brake[$fi]
    $gas[$k] = [int]$j.gas[$fi]
}

$lapLen = $s[$m - 1]
if ($lapLen -lt 100.0) { throw "Lap length abnormal: $lapLen" }

$xmin = ($xs | Measure-Object -Minimum).Minimum
$xmax = ($xs | Measure-Object -Maximum).Maximum
$zmin = ($zs | Measure-Object -Minimum).Minimum
$zmax = ($zs | Measure-Object -Maximum).Maximum
$innerFrac = [Math]::Max(0.0, [Math]::Min(0.45, $InnerMarginPercent / 100.0))
$bmpW = $ImageWidth; $bmpH = $ImageHeight
$iw = $bmpW * (1.0 - 2.0 * $innerFrac); $ih = $bmpH * (1.0 - 2.0 * $innerFrac)
$rw = [Math]::Max(1e-9, $xmax - $xmin); $rz = [Math]::Max(1e-9, $zmax - $zmin)
$sc = [Math]::Min($iw / $rw, $ih / $rz)
$offX = $bmpW * $innerFrac + ($iw - $sc * $rw) / 2.0
$offZ = $bmpH * $innerFrac + ($ih - $sc * $rz) / 2.0

$pxi = New-Object int[] $m
$pzi = New-Object int[] $m
for ($k = 0; $k -lt $m; $k++) {
    $pxd = $offX + ($xs[$k] - $xmin) * $sc
    if ($FlipWorldZ.IsPresent) { $pzd = $offZ + ($zs[$k] - $zmin) * $sc }
    else { $pzd = $offZ + ($zmax - $zs[$k]) * $sc }
    $pxi[$k] = Clamp-Int ([int][Math]::Round($pxd)) 0 ($bmpW - 1)
    $pzi[$k] = Clamp-Int ([int][Math]::Round($pzd)) 0 ($bmpH - 1)
}
if (-not $NoVerticalFlip.IsPresent) {
    for ($k = 0; $k -lt $m; $k++) { $pzi[$k] = $bmpH - 1 - $pzi[$k] }
}

$bmp = New-Object System.Drawing.Bitmap $bmpW, $bmpH
$g = [System.Drawing.Graphics]::FromImage($bmp)
$g.SmoothingMode = [System.Drawing.Drawing2D.SmoothingMode]::AntiAlias
$g.TextRenderingHint = [System.Drawing.Text.TextRenderingHint]::AntiAlias
$g.Clear([System.Drawing.Color]::White)
$fontTitle = New-CjkDrawingFont $FontSizeTitle ([System.Drawing.FontStyle]::Bold)
$fontMk = New-CjkDrawingFont $FontSizeMarker ([System.Drawing.FontStyle]::Bold)
$brushTxt = New-Object System.Drawing.SolidBrush ([System.Drawing.Color]::FromArgb(240, 30, 30, 30))
$penTrace = New-Object System.Drawing.Pen ([System.Drawing.Color]::FromArgb(200, 40, 90, 200)), 3
$brushRed = New-Object System.Drawing.SolidBrush ([System.Drawing.Color]::FromArgb(230, 200, 40, 40))
$brushGreen = New-Object System.Drawing.SolidBrush ([System.Drawing.Color]::FromArgb(230, 30, 150, 50))
$brushSf = New-Object System.Drawing.SolidBrush ([System.Drawing.Color]::FromArgb(255, 200, 130, 0))
$penLeader = New-Object System.Drawing.Pen ([System.Drawing.Color]::FromArgb(160, 90, 90, 90)), 1.0

for ($k = 1; $k -lt $m; $k++) {
    $g.DrawLine($penTrace, $pxi[$k - 1], $pzi[$k - 1], $pxi[$k], $pzi[$k])
}

$occupied = New-Object 'System.Collections.Generic.List[System.Drawing.RectangleF]'

function Test-RectOverlap([System.Drawing.RectangleF]$a, [System.Drawing.RectangleF]$b, [float]$pad) {
    $ax1 = $a.Left - $pad; $ay1 = $a.Top - $pad; $ax2 = $a.Right + $pad; $ay2 = $a.Bottom + $pad
    $bx1 = $b.Left - $pad; $by1 = $b.Top - $pad; $bx2 = $b.Right + $pad; $by2 = $b.Bottom + $pad
    return -not (($ax2 -lt $bx1) -or ($ax1 -gt $bx2) -or ($ay2 -lt $by1) -or ($ay1 -gt $by2))
}

function New-LabelPlacement {
    param($Graphics, $Font, [string]$Text, [int]$cx, [int]$cy, [int]$imgW, [int]$imgH, $Occupied, [float[]]$OffsetCandidates)
    $sz = $Graphics.MeasureString($Text, $Font)
    $w = $sz.Width + 6; $h = $sz.Height + 4
    $pad = [float]4
    for ($ci = 0; $ci -lt $OffsetCandidates.Length; $ci += 2) {
        $tx = [float]($cx + $OffsetCandidates[$ci]); $ty = [float]($cy + $OffsetCandidates[$ci + 1])
        if ($tx + $w -gt $imgW - 4) { $tx = [float]($imgW - 4 - $w) }
        if ($tx -lt 4) { $tx = 4 }
        if ($ty + $h -gt $imgH - 4) { $ty = [float]($imgH - 4 - $h) }
        if ($ty -lt 4) { $ty = 4 }
        $rc = [System.Drawing.RectangleF]::new($tx, $ty, $w, $h)
        $hit = $false
        foreach ($o in $Occupied) { if (Test-RectOverlap $rc $o $pad) { $hit = $true; break } }
        if (-not $hit) {
            [void]$Occupied.Add($rc)
            return @{ Tx = $tx; Ty = $ty; W = $w; H = $h }
        }
    }
    # Fallback: radial search around anchor to minimize collisions in dense areas.
    for ($rad = 26.0; $rad -le 190.0; $rad += 12.0) {
        for ($ang = 0.0; $ang -lt 360.0; $ang += 20.0) {
            $rx = [Math]::Cos($ang * [Math]::PI / 180.0) * $rad
            $ry = [Math]::Sin($ang * [Math]::PI / 180.0) * $rad
            $tx = [float]($cx + $rx)
            $ty = [float]($cy + $ry)
            if ($tx + $w -gt $imgW - 4) { $tx = [float]($imgW - 4 - $w) }
            if ($tx -lt 4) { $tx = 4 }
            if ($ty + $h -gt $imgH - 4) { $ty = [float]($imgH - 4 - $h) }
            if ($ty -lt 4) { $ty = 4 }
            $rc = [System.Drawing.RectangleF]::new($tx, $ty, $w, $h)
            $hit = $false
            foreach ($o in $Occupied) { if (Test-RectOverlap $rc $o $pad) { $hit = $true; break } }
            if (-not $hit) {
                [void]$Occupied.Add($rc)
                return @{ Tx = $tx; Ty = $ty; W = $w; H = $h }
            }
        }
    }
    # Last resort: place at corner to guarantee visibility.
    $tx0 = [float]4; $ty0 = [float]4
    $rc0 = [System.Drawing.RectangleF]::new($tx0, $ty0, $w, $h)
    [void]$Occupied.Add($rc0)
    return @{ Tx = $tx0; Ty = $ty0; W = $w; H = $h }
}

function Draw-StringWithLeader {
    param($Graphics, $Font, $Brush, $PenL, [int]$cx, [int]$cy, [string]$Text, $Place)
    $Graphics.DrawString($Text, $Font, $Brush, $Place.Tx, $Place.Ty)
    $mx = $Place.Tx + $Place.W / 2.0; $my = $Place.Ty + $Place.H / 2.0
    $Graphics.DrawLine($PenL, [float]$cx, [float]$cy, $mx, $my)
}

$sfOff = [float[]]@(20.0, -28.0, -120.0, -28.0, 20.0, 22.0)
$sfPl = New-LabelPlacement $g $fontMk $cap.sf $pxi[0] $pzi[0] $bmpW $bmpH $occupied $sfOff
$g.FillEllipse($brushSf, $pxi[0] - 10, $pzi[0] - 10, 20, 20)
Draw-StringWithLeader $g $fontMk $brushTxt $penLeader $pxi[0] $pzi[0] $cap.sf $sfPl

$bOff = [float[]]@(16.0, -28.0, -120.0, -28.0, 20.0, 22.0, -130.0, 24.0, 95.0, -34.0, 110.0, 12.0)
$gOff = [float[]]@(-16.0, 26.0, 90.0, 26.0, -26.0, -18.0, 110.0, -24.0, -120.0, 30.0, 24.0, 44.0)

$prevBrakeRunEnd = -1
$prevGasRunEnd = -1
$events = New-Object System.Collections.Generic.List[object]
$debugRows = New-Object System.Collections.Generic.List[object]
for ($ti = 0; $ti -lt 14; $ti++) {
    $f0 = $boundaries[$ti]; $f1 = $boundaries[$ti + 1]
    $sLo = [Math]::Max(0.0, ($f0 * $lapLen) - $SectorExpandMeters)
    $sHi = [Math]::Min($lapLen, ($f1 * $lapLen) + $SectorExpandMeters)
    $ff0 = $sLo / $lapLen
    $ff1 = $sHi / $lapLen
    $k0, $k1 = Find-KRangeForArc $s $lapLen $ff0 $ff1 $m

    # Avoid repeated brake markers when one long brake run spans adjacent sectors.
    $searchK0 = [Math]::Max($k0, $prevBrakeRunEnd + 1)
    $bk = Find-FirstRisingSustainedAbove $brk $searchK0 $k1 $BrakePedalThreshold $brkFrames
    $brkEnd = -1
    if ($bk -ge 0) {
        $brkEnd = Get-ContiguousRunEnd $brk $bk $k1 $BrakePedalThreshold
        if ($brkEnd -gt $prevBrakeRunEnd) { $prevBrakeRunEnd = $brkEnd }
    }

    $gasFrom = $k0
    if ($brkEnd -ge 0) { $gasFrom = [Math]::Min($k1, $brkEnd + 1) }
    $gasSearchK0 = [Math]::Max($gasFrom, $prevGasRunEnd + 1)

    $tkRise = Find-FirstRisingSustainedAbove $gas $gasSearchK0 $k1 $GasPedalThreshold $gasFrames
    $tkSustain = -1
    $tkReapply = -1
    $tk = $tkRise
    $tkSource = 'rise'
    if ($tk -lt 0) {
        # Fallback: if no clean rising edge exists in this window, still capture first sustained high-gas point.
        $tkSustain = Find-FirstSustainedAbove $gas $gasSearchK0 $k1 $GasPedalThreshold $gasFrames
        $tk = $tkSustain
        $tkSource = 'sustain'
    }
    if ($tk -lt 0) {
        # Fallback 2: capture lower-threshold throttle reapply when speed rises but full gas threshold isn't reached.
        $tkReapply = Find-FirstGasReapply $gas $brk $gasSearchK0 $k1 $GasReapplyThreshold $GasReapplyDelta $GasReapplyBrakeMax $gasReapplyFrames
        $tk = $tkReapply
        $tkSource = 'reapply'
    }
    if ($tk -lt 0) { $tkSource = 'none' }
    if ($tk -ge 0) {
        $gasEnd = Get-ContiguousRunEnd $gas $tk $k1 $GasPedalThreshold
        if ($gasEnd -gt $prevGasRunEnd) { $prevGasRunEnd = $gasEnd }
    }

    if ($bk -ge 0) {
        [void]$events.Add([pscustomobject]@{
            K = $bk
            Kind = 'brake'
            Sector = ($ti + 1)
            Source = 'rise'
            GasValue = 0
            Speed = [int][math]::Round($sp[$bk], 0)
            Px = $pxi[$bk]
            Py = $pzi[$bk]
        })
    }

    if ($tk -ge 0) {
        [void]$events.Add([pscustomobject]@{
            K = $tk
            Kind = 'gas'
            Sector = ($ti + 1)
            Source = $tkSource
            GasValue = [int]$gas[$tk]
            Speed = [int][math]::Round($sp[$tk], 0)
            Px = $pxi[$tk]
            Py = $pzi[$tk]
        })
    }

    if ($DebugEventTrace.IsPresent) {
        $secMaxGas = ($gas[$k0..$k1] | Measure-Object -Maximum).Maximum
        $secMaxBrk = ($brk[$k0..$k1] | Measure-Object -Maximum).Maximum
        [void]$debugRows.Add([pscustomobject]@{
            Phase = 'sector'
            Sector = ('T{0}' -f ($ti + 1))
            k0 = $k0
            k1 = $k1
            searchBrakeK0 = $searchK0
            bk = $bk
            brkEnd = $brkEnd
            gasSearchK0 = $gasSearchK0
            tkRise = $tkRise
            tkSustain = $tkSustain
            tkReapply = $tkReapply
            tkPicked = $tk
            tkSource = $tkSource
            secMaxGas = $secMaxGas
            secMaxBrk = $secMaxBrk
        })
    }
}

$markerId = 0
$orderedEvents = @($events | Sort-Object K, Kind)

# Global补漏:若两次刹车之间无油门点,则在中间区间再做一次补油搜索。
$brakeEvents = @($orderedEvents | Where-Object { $_.Kind -eq 'brake' } | Sort-Object K)
if ($brakeEvents.Count -ge 2) {
    for ($bi = 0; $bi -lt $brakeEvents.Count - 1; $bi++) {
        $kA = [int]$brakeEvents[$bi].K
        $kB = [int]$brakeEvents[$bi + 1].K
        if (($kB - $kA) -lt 3) { continue }

        $hasGasBetween = $false
        foreach ($ev2 in $orderedEvents) {
            if ($ev2.Kind -eq 'gas' -and $ev2.K -gt $kA -and $ev2.K -lt $kB) {
                $hasGasBetween = $true
                break
            }
        }
        if ($hasGasBetween) { continue }

        $g0 = $kA + 1
        $g1 = $kB - 1
        $tkMid = Find-FirstRisingSustainedAbove $gas $g0 $g1 $GasPedalThreshold $gasFrames
        if ($tkMid -lt 0) {
            $tkMid = Find-FirstSustainedAbove $gas $g0 $g1 $GasPedalThreshold $gasFrames
        }
        if ($tkMid -lt 0) {
            $tkMid = Find-FirstGasReapply $gas $brk $g0 $g1 $GasReapplyThreshold $GasReapplyDelta $GasReapplyBrakeMax $gasReapplyFrames
        }
        if ($tkMid -lt 0 -and $AllowOverlapThrottleBetweenBrakes) {
            # Only in brake-to-brake gaps: allow overlap throttle reapply without brake-max constraint.
            $tkMid = Find-FirstGasReapplyOverlap $gas $g0 $g1 $GasReapplyThreshold $GasReapplyDelta $gasReapplyFrames
            if ($tkMid -lt 0) {
                # If gas is already high in this gap (no rise edge), capture the first sustained high-gas sample.
                $tkMid = Find-FirstSustainedAbove $gas $g0 $g1 $GasReapplyThreshold $gasReapplyFrames
            }
            if ($tkMid -lt 0) {
                # Final fallback for brake-to-brake gap: pick max-gas point in gap to avoid missing obvious refill.
                $bestK = -1
                $bestG = -1
                for ($kk = $g0; $kk -le $g1; $kk++) {
                    if ($gas[$kk] -gt $bestG) { $bestG = $gas[$kk]; $bestK = $kk }
                }
                if ($bestG -ge $GasReapplyThreshold) { $tkMid = $bestK }
            }
        }
        if ($DebugEventTrace.IsPresent) {
            [void]$debugRows.Add([pscustomobject]@{
                Phase = 'global_gap_probe'
                Sector = ('T{0}->T{1}' -f $brakeEvents[$bi].Sector, $brakeEvents[$bi + 1].Sector)
                k0 = $g0
                k1 = $g1
                searchBrakeK0 = ''
                bk = $kA
                brkEnd = $kB
                gasSearchK0 = $g0
                tkRise = ''
                tkSustain = ''
                tkReapply = ''
                tkPicked = $tkMid
                tkSource = if ($tkMid -ge 0) { 'global_probe_hit' } else { 'global_probe_miss' }
                secMaxGas = ($gas[$g0..$g1] | Measure-Object -Maximum).Maximum
                secMaxBrk = ($brk[$g0..$g1] | Measure-Object -Maximum).Maximum
            })
        }
        if ($tkMid -ge 0) {
            [void]$events.Add([pscustomobject]@{
                K = $tkMid
                Kind = 'gas'
                Sector = 0
                Source = 'global_gap_fill_overlap_ok'
                GasValue = [int]$gas[$tkMid]
                Speed = [int][math]::Round($sp[$tkMid], 0)
                Px = $pxi[$tkMid]
                Py = $pzi[$tkMid]
            })
            if ($DebugEventTrace.IsPresent) {
                [void]$debugRows.Add([pscustomobject]@{
                    Phase = 'global_gap_fill'
                    Sector = ('T{0}->T{1}' -f $brakeEvents[$bi].Sector, $brakeEvents[$bi + 1].Sector)
                    k0 = $g0
                    k1 = $g1
                    searchBrakeK0 = ''
                    bk = $brakeEvents[$bi].K
                    brkEnd = $brakeEvents[$bi + 1].K
                    gasSearchK0 = $g0
                    tkRise = ''
                    tkSustain = ''
                    tkReapply = ''
                    tkPicked = $tkMid
                    tkSource = 'global_gap_fill'
                    secMaxGas = ($gas[$g0..$g1] | Measure-Object -Maximum).Maximum
                    secMaxBrk = ($brk[$g0..$g1] | Measure-Object -Maximum).Maximum
                })
            }
        }
    }
    $orderedEvents = @($events | Sort-Object K, Kind)
}

# Second-pass robust补漏(仅连续刹车之间):
# If a brake-to-brake gap still has no gas marker, insert one at max-gas position in that gap.
if ($AllowOverlapThrottleBetweenBrakes) {
    $orderedEvents = @($events | Sort-Object K, Kind)
    $brakeEvents2 = @($orderedEvents | Where-Object { $_.Kind -eq 'brake' } | Sort-Object K)
    if ($brakeEvents2.Count -ge 2) {
        for ($bi2 = 0; $bi2 -lt $brakeEvents2.Count - 1; $bi2++) {
            $kA2 = [int]$brakeEvents2[$bi2].K
            $kB2 = [int]$brakeEvents2[$bi2 + 1].K
            if (($kB2 - $kA2) -lt 3) { continue }

            $hasGasBetween2 = $false
            foreach ($evx in $orderedEvents) {
                if ($evx.Kind -eq 'gas' -and $evx.K -gt $kA2 -and $evx.K -lt $kB2) {
                    $hasGasBetween2 = $true
                    break
                }
            }
            if ($hasGasBetween2) { continue }

            $g02 = $kA2 + 1
            $g12 = $kB2 - 1
            $bestK2 = -1
            $bestG2 = -1
            for ($kk2 = $g02; $kk2 -le $g12; $kk2++) {
                if ($gas[$kk2] -gt $bestG2) { $bestG2 = $gas[$kk2]; $bestK2 = $kk2 }
            }
            if ($bestK2 -ge 0 -and $bestG2 -ge $GasReapplyThreshold) {
                [void]$events.Add([pscustomobject]@{
                    K = $bestK2
                    Kind = 'gas'
                    Sector = 0
                    Source = 'global_gap_force_max'
                    GasValue = [int]$gas[$bestK2]
                    Speed = [int][math]::Round($sp[$bestK2], 0)
                    Px = $pxi[$bestK2]
                    Py = $pzi[$bestK2]
                })
            }
        }
        $orderedEvents = @($events | Sort-Object K, Kind)
    }
}

# Rule: between two consecutive brake points, keep at most one gas point.
if ($orderedEvents.Count -gt 0) {
    $removeIdx = New-Object 'System.Collections.Generic.HashSet[int]'
    $brakeIdx = New-Object System.Collections.Generic.List[int]
    for ($i = 0; $i -lt $orderedEvents.Count; $i++) {
        if ($orderedEvents[$i].Kind -eq 'brake') { [void]$brakeIdx.Add($i) }
    }
    for ($bi3 = 0; $bi3 -lt $brakeIdx.Count - 1; $bi3++) {
        $ia = $brakeIdx[$bi3]
        $ib = $brakeIdx[$bi3 + 1]
        $gasCandidates = New-Object System.Collections.Generic.List[int]
        for ($i = $ia + 1; $i -lt $ib; $i++) {
            if ($orderedEvents[$i].Kind -eq 'gas') { [void]$gasCandidates.Add($i) }
        }
        if ($gasCandidates.Count -le 1) { continue }
        # Keep earliest gas marker between two brake markers.
        $keep = $gasCandidates | Sort-Object { [int]$orderedEvents[$_].K } | Select-Object -First 1
        foreach ($gi in $gasCandidates) {
            if ($gi -ne $keep) { [void]$removeIdx.Add([int]$gi) }
        }
    }
    if ($removeIdx.Count -gt 0) {
        $filtered = New-Object System.Collections.Generic.List[object]
        for ($i = 0; $i -lt $orderedEvents.Count; $i++) {
            if (-not $removeIdx.Contains($i)) { [void]$filtered.Add($orderedEvents[$i]) }
        }
        $orderedEvents = $filtered.ToArray()
    }
}

foreach ($ev in $orderedEvents) {
    $markerId++
    $lbl = ('A{0} {1} km/h' -f $markerId, $ev.Speed)
    if ($ev.Kind -eq 'brake') {
        $pl = New-LabelPlacement $g $fontMk $lbl $ev.Px $ev.Py $bmpW $bmpH $occupied $bOff
        $g.FillEllipse($brushRed, $ev.Px - 7, $ev.Py - 7, 14, 14)
    } else {
        $pl = New-LabelPlacement $g $fontMk $lbl $ev.Px $ev.Py $bmpW $bmpH $occupied $gOff
        $g.FillEllipse($brushGreen, $ev.Px - 7, $ev.Py - 7, 14, 14)
    }
    Draw-StringWithLeader $g $fontMk $brushTxt $penLeader $ev.Px $ev.Py $lbl $pl
}

if ($DebugEventTrace.IsPresent) {
    $aRows = New-Object System.Collections.Generic.List[object]
    $aId = 0
    foreach ($ev in $orderedEvents) {
        $aId++
        [void]$aRows.Add([pscustomobject]@{
            Phase = 'A_sequence'
            Sector = if ($ev.Sector -gt 0) { 'T' + $ev.Sector } else { '-' }
            A = 'A' + $aId
            Kind = $ev.Kind
            Source = $ev.Source
            K = $ev.K
            AbsFrame = $idx[$ev.K]
            ArcS_m = [Math]::Round($s[$ev.K], 3)
            Speed_kmh = $ev.Speed
        })
    }

    $gapRows = New-Object System.Collections.Generic.List[object]
    $brOnly = @($aRows | Where-Object { $_.Kind -eq 'brake' })
    for ($gi = 0; $gi -lt $brOnly.Count - 1; $gi++) {
        $a = $brOnly[$gi]
        $b = $brOnly[$gi + 1]
        $ka = [int]$a.K; $kb = [int]$b.K
        if (($kb - $ka) -lt 2) { continue }
        $lo = $ka + 1; $hi = $kb - 1
        $hasGas = ($aRows | Where-Object { $_.Kind -eq 'gas' -and [int]$_.K -gt $ka -and [int]$_.K -lt $kb } | Select-Object -First 1)
        $maxGas = ($gas[$lo..$hi] | Measure-Object -Maximum).Maximum
        $maxBrk = ($brk[$lo..$hi] | Measure-Object -Maximum).Maximum
        $run180 = Get-LongestRunAbove $gas $lo $hi 180
        $run60 = Get-LongestRunAbove $gas $lo $hi 60
        $run40 = Get-LongestRunAbove $gas $lo $hi 40
        [void]$gapRows.Add([pscustomobject]@{
            Phase = 'brake_gap'
            Sector = ($a.A + '->' + $b.A)
            A = ''
            Kind = ''
            Source = if ($hasGas) { 'has_gas' } else { ("no_gas(run180={0},run60={1},run40={2})" -f $run180, $run60, $run40) }
            K = "$lo..$hi"
            AbsFrame = "$($idx[$lo])..$($idx[$hi])"
            ArcS_m = [Math]::Round(($s[$lo] + $s[$hi]) / 2.0, 3)
            Speed_kmh = ''
            MaxGas = $maxGas
            MaxBrake = $maxBrk
        })
    }

    $all = @($debugRows + $aRows + $gapRows) | ForEach-Object {
        [pscustomobject]@{
            Phase = if ($_.PSObject.Properties.Name -contains 'Phase') { $_.Phase } else { '' }
            Sector = if ($_.PSObject.Properties.Name -contains 'Sector') { $_.Sector } else { '' }
            A = if ($_.PSObject.Properties.Name -contains 'A') { $_.A } else { '' }
            Kind = if ($_.PSObject.Properties.Name -contains 'Kind') { $_.Kind } else { '' }
            Source = if ($_.PSObject.Properties.Name -contains 'Source') { $_.Source } else { '' }
            K = if ($_.PSObject.Properties.Name -contains 'K') { $_.K } else { '' }
            AbsFrame = if ($_.PSObject.Properties.Name -contains 'AbsFrame') { $_.AbsFrame } else { '' }
            ArcS_m = if ($_.PSObject.Properties.Name -contains 'ArcS_m') { $_.ArcS_m } else { '' }
            Speed_kmh = if ($_.PSObject.Properties.Name -contains 'Speed_kmh') { $_.Speed_kmh } else { '' }
            MaxGas = if ($_.PSObject.Properties.Name -contains 'MaxGas') { $_.MaxGas } else { '' }
            MaxBrake = if ($_.PSObject.Properties.Name -contains 'MaxBrake') { $_.MaxBrake } else { '' }
            k0 = if ($_.PSObject.Properties.Name -contains 'k0') { $_.k0 } else { '' }
            k1 = if ($_.PSObject.Properties.Name -contains 'k1') { $_.k1 } else { '' }
            searchBrakeK0 = if ($_.PSObject.Properties.Name -contains 'searchBrakeK0') { $_.searchBrakeK0 } else { '' }
            bk = if ($_.PSObject.Properties.Name -contains 'bk') { $_.bk } else { '' }
            brkEnd = if ($_.PSObject.Properties.Name -contains 'brkEnd') { $_.brkEnd } else { '' }
            gasSearchK0 = if ($_.PSObject.Properties.Name -contains 'gasSearchK0') { $_.gasSearchK0 } else { '' }
            tkRise = if ($_.PSObject.Properties.Name -contains 'tkRise') { $_.tkRise } else { '' }
            tkSustain = if ($_.PSObject.Properties.Name -contains 'tkSustain') { $_.tkSustain } else { '' }
            tkReapply = if ($_.PSObject.Properties.Name -contains 'tkReapply') { $_.tkReapply } else { '' }
            tkPicked = if ($_.PSObject.Properties.Name -contains 'tkPicked') { $_.tkPicked } else { '' }
            tkSource = if ($_.PSObject.Properties.Name -contains 'tkSource') { $_.tkSource } else { '' }
            secMaxGas = if ($_.PSObject.Properties.Name -contains 'secMaxGas') { $_.secMaxGas } else { '' }
            secMaxBrk = if ($_.PSObject.Properties.Name -contains 'secMaxBrk') { $_.secMaxBrk } else { '' }
        }
    }
    $all | Export-Csv -LiteralPath $DebugOutputPath -NoTypeInformation -Encoding UTF8
    $a1415 = $gapRows | Where-Object { $_.Sector -eq 'A14->A15' } | Select-Object -First 1
    if ($null -ne $a1415) {
        Write-Host ("Debug A14->A15: source={0} maxGas={1} maxBrake={2} gapK={3}" -f $a1415.Source, $a1415.MaxGas, $a1415.MaxBrake, $a1415.K)
    }
    Write-Host "Debug trace saved: $DebugOutputPath"
}

$sub = ('dt={0}ms brake>={1}s thr={2} gas>={3} expand={4}m' -f [int]($dt * 1000), $BrakeMinSeconds, $ThrottleMinSeconds, $GasPedalThreshold, $SectorExpandMeters)
$title = $cap.titlePrefix + '  Lap=' + $Lap + '  L=' + [math]::Round($lapLen, 0) + 'm  ' + $sub + '  ' + (Get-Date -Format 'yyyy-MM-dd HH:mm')
$g.DrawString($title, $fontTitle, $brushTxt, 10.0, 8.0)
$leg = $cap.legend + '  |  ' + $sub
$g.DrawString($leg, $fontMk, $brushTxt, 10.0, [float]($bmpH - 42))

$bmp.Save($OutputPath, [System.Drawing.Imaging.ImageFormat]::Png)
$g.Dispose(); $bmp.Dispose()
$penTrace.Dispose(); $penLeader.Dispose()
$brushRed.Dispose(); $brushGreen.Dispose(); $brushTxt.Dispose(); $brushSf.Dispose()
$fontTitle.Dispose(); $fontMk.Dispose()
Write-Host "Saved: $OutputPath"

打包exe

没想到打包exe,这个简单的需求反而是最麻烦的,最难处理的。

AI生成的都是powershell的脚本,我想把它打包成一个exe,可以方便使用一些。

打包 exe 重写了三遍,第一遍打包 exe 还要调用脚本,那这个 exe 的意义何在;第二遍打包各种路径弄不对;第三遍打包增加测试方法以后,总算给出来一个能用的 exe 了。

#Requires -Version 5.1
# 将 BuildIdealLineFromReplay.ps1 / DrawZhuhaiLapCorners.ps1 打成 exe。
# 先输出到 %TEMP% 再复制到 tools,避免目标 exe 被占用时 PS2EXE 无法删除旧文件导致打包失败。
$ErrorActionPreference = 'Stop'
$here = $PSScriptRoot
Import-Module (Join-Path $here 'ps2exe-module\ps2exe.psd1') -Force

function Stop-ToolProcess([string]$exeFileName) {
    $base = [IO.Path]::GetFileNameWithoutExtension($exeFileName)
    Get-Process -Name $base -ErrorAction SilentlyContinue | Stop-Process -Force -ErrorAction SilentlyContinue
}

function Copy-ExeToTools {
    param([string]$TempExe, [string]$DestExe)
    Copy-Item -LiteralPath $TempExe -Destination $DestExe -Force
}

$targets = @(
    @{ In = 'BuildIdealLineFromReplay.ps1'; Out = 'BuildIdealLineFromReplay.exe'; Title = 'BuildIdealLineFromReplay'; ConHost = $true },
    @{ In = 'DrawZhuhaiLapCorners.ps1'; Out = 'DrawZhuhaiLapCorners.exe'; Title = 'DrawZhuhaiLapCorners'; ConHost = $false }
)
foreach ($t in $targets) {
    $inPath = Join-Path $here $t.In
    $outPath = Join-Path $here $t.Out
    Write-Host "Building $outPath ..."
    Stop-ToolProcess $t.Out
    Start-Sleep -Milliseconds 400
    $tmp = Join-Path $env:TEMP ('ps2exe_' + [guid]::NewGuid().ToString('N') + '_' + $t.Out)
    try {
        # Draw:System.Drawing 用 -STA;-conHost 会导致脚本未跑完、PNG 不落盘。
        # BuildIdealLine:-conHost 便于无控制台/部分自动化场景结束等待。
        if ($t.ConHost) {
            Invoke-ps2exe -inputFile $inPath -outputFile $tmp -conHost -title $t.Title
        } else {
            Invoke-ps2exe -inputFile $inPath -outputFile $tmp -STA -noConsole:$false -title $t.Title
        }
        Copy-ExeToTools -TempExe $tmp -DestExe $outPath
        Write-Host "  -> $outPath"
    } finally {
        Remove-Item -LiteralPath $tmp -Force -ErrorAction SilentlyContinue
    }
}
Write-Host 'Done.'

Summary

最终生成的代码如下,也一起打包了exe

https://github.com/elmagnificogi/ACRecord2AILine.git

对于AI来完成一个项目一些前提:

  1. 项目是否可行,前期需要一些验证性的方案摸底,确定技术方案是否可行,以及AI使用何种方案进行
  2. 需求需要明确,越细致越好
  3. AI生成的结果需要有基础的测试用例,能量化到具体数值、行为、结果最好,图片化的结果比较麻烦需要人工反馈,给AI自己识别还是存在一定误差的
  4. 建议最好把需求点拆成一个阶段一个阶段的,每一步都完成验证以后再进行下一步,而不是一个总体目标和测试结果,会导致AI自己卡在其中反复迭代,无限消耗token,还得不到要的结果
    • 这一点要着重强调,实际上Cursor有200K的上下文,而AI一旦陷入其中就会出现上下文不够用的情况,继而导致核心需求或者描述的上下文被丢失,AI进入恶性循环中,再也出不去

再完成转exe和画分析图的过程中都出现了上下文不够用的情况,我只好重新总结要求,新开一个agent再次进行,而这样再次进行的上下文大概只用了1/3就完成了所有新工作

当AI出现试错或者走向错误分支的时候,这部分上下文可能会占用很多tokens消耗,最好人工发现以后,简单总结,避免再走向错误的道路中

image-20260406195552167

一共就写这么不到 2000 行的代码,2 个需求 + 一个 CI 打包,去掉我 4 月前几天的消耗,大概 400 万 tokens,完成这个消耗了 5400 万 tokens,这里面有很多 cache,但是总体量就得有这么多,平均一行代码消耗 2 万多 tokens,还是很恐怖的。

这么一个需求消耗了接近1/3的Cursor用量,核算下来大概是5刀,30来块钱,看起来挺少的,但是总共耗时大概是7-8小时,是我全程辅助以后的结果。

如果给我7-8小时,纯工作时间,估计也能做到差不多的程度,但是消耗的脑力就很多了,我需要从头开始学习和实验。

后续如果再用AI做需求,再完善一下方法论,再给到AI应该会更快更好一些。

艺术与审美,第三阶段学习

2026-03-27 00:00:00

Foreword

2026.1.13 开始学习艺术与审美的第三阶段课程:视觉艺术方法论,接着之前的审美课二阶段内容继续学。

这个课是 25 年 10 月才开始上的,我问他的时候刚好上完,课程内容还没总结完,基本都是直播录屏,我也直接加进了直播群。

看了下这个课程大概有 230 多人,这已经是三阶段课程了,之前两阶段的人应该会更多一些。

艺术与审美

艺术与审美课的三阶段内容如下,价格是 1680,总共是 12 节大课,大概 36 小时,课程宣传视频也确实是他刚开课的时候发的。

从目录来看,这节课涉及具体艺术落地,应该比较有用,或者说能对生活中的一些设计细节带来额外启发和思考。

目录:

  1. 淘宝实木家具设计研究报告
  2. 包豪斯-设计的诞生
  3. 发型设计核心逻辑解析
  4. 穿搭设计核心逻辑解析
  5. 字体出版设计史
  6. 包装设计核心逻辑解析
  7. 中国古代陶瓷审美发展规律
  8. 中国现代陶瓷设计趋势报告
  9. 中国潮玩公司设计研究报告
  10. 万代发展规律解析
  11. 设计大历史
  12. 游戏人生存指南

这些课程内容或者研究是唯伟老师团队自己做出来的,带有一定的主观性,再加上他本身不是这个领域的人,他只是看和研究别人为什么这么做和当下的艺术界有什么联系和影响,所以只当作入门了解即可,不必当真。

笔记

淘宝实木家具设计研究报告

这一课看似是拿家具为例子,本质上还是在论证之前他说的一些理论在这里是如何应用的。

柜子这个东西从平面到立体,一些隐形的规则,虚实,阴影带来的视觉体验,让你觉得某个柜子的设计好看或者不好看

结构即审美,就是单纯的结构设计就能让你看到美感,屏蔽了很多复杂的装饰品,类似日本的建筑师安藤忠雄,清水混凝土,他做出来的就非常好看。

在柜子这里又说到了另外一个概念,类似虚实,柜体本身的设计如果偏向简洁,那么对应的这个柜体本身兼容性就更强,他就能和更多的东西组合搭配在一起使用。但是反过来,如果柜子本身的装饰面设计的非常复杂,抢眼,那么他与其他家具或者其他装饰品要搭配在一起就比较困难,这个东西本质上还是从整体去着眼,而非从柜子本身去着眼的逻辑,整体看一面墙或者家的范围要和谐统一,一旦所有家具都是极其复杂没有虚实结合的时候,那么给人的压力就非常大,就不适合了。同样的这个柜子对于家,物理面积就有了需求,越是复杂的柜子越需要更大的空间去承载,而越小的家,越适合简单的柜子

中间说明了丹麦家具的历史地位,举例当然是椅子王,说家具离不开那帮做椅子的人。后续也很简单:家具里各种功能和用户需求的小细节是第一位的,满足了基础实用需求以后,美才会被提上日程。不过这里说得有点绝对,实际上当你有足够预算的时候,第一时间考虑的往往就是美,因为功能在这里满足不了,可能可以在其他地方满足,预算能做的事情太多了。

这里说的设计、美感上的秩序感或者说有规律的那种感觉,是东亚人的潜意识,人心里潜意识就觉得这个东西应该是有规律的,而这个规律又来自哪里,来自于古代的权威或者说几千年来建立的统治阶层对于下面人的潜意识,这个东西不是简简单单的就能抹除的,类似中国的对称感,这种潜意识的需求是很难被自我发现的。

总结来说,中低端用户是不会为美付费的,而高端用户是会为此付出溢价,推理就能得到,小工作室做中低端死路一条,无论是生产资料还是成本都比不过正规工厂,而高端才有可能盘活。

最后说了一下新时代是新能源的一代,我只能说这个说法还是有些偏颇。这个时代确实进入了下一个阶段,但到底是新能源还是其他方向,不好说。理性或者偏理性的设计一定会有,它是时代浪潮的一部分,这批人也正在成长。结合之前看到的政治课内容,这个时代已经是财富再分配时代了,老登永远是老登,只要不是特意作死,很难更换,而继承者也是一样,至于在时代浪潮里起来的人,终究是少数。

这个课的内容确实和我之前想的差不多,唯伟确实不能给做家具设计的什么东西,只能是对小白设计者或者观众给出一些浅显的内容,他是按照他的方法论,他的世界观去理解和解释其他内容的,到这里我基本看明白了,估计剩下的几课内容都是类似的方式。

包豪斯-设计的诞生

包豪斯诞生的时代是刚好工业革命的时代,是机器大量替代人工刚刚开始,此时时代需求的就正好是大量工人或者普通人的需求。而包豪斯就是格罗皮乌斯提出来的一种用来解决普通人需求的产品的设计理念。包豪斯内部主要就是功能和形式之争,到底是功能优先还是艺术家的形式优先,在当时的那个年代,他们是超前的,甚至超脱当时的生产力水平,这导致他们设计的东西实际是难以落地的,虽然落地了,但是在当时是根本无法给普通人使用的,是无法惠民的。但是这部分概念却保留和影响了下面好几代的设计师,直到现在生产力的大爆发,总算是可以普遍满足包豪斯设计的初衷了。但这个背后是生产力大爆发,是人民群众的精神物质水平巨幅提高,是大家需要满足精神需求了,才逐渐和包豪斯的理念契合。而在当下,人们早就不满足于包豪斯的概念了,当你有权、有钱、有闲时,你的精神追求就成为了你的主要需求,对应的艺术概念也就开始百花齐放了,有需求就会有市场,有响应。

再说回未来,如果具身智能大爆发,可能很多人都会成为脱产者。当你不再被生存压迫时,就会开始精神空虚,对应的未来可能就是各种小众艺术崛起的时代。

之前看到一个龙头挂香竹,单纯就是把实用和结构结合在一起的东西,你看到就会觉得很好看。

image-20260113191135897

这个东西就很好的把形式和功能结合到了一起。

后面说了一下美,独立游戏等相关问题,总的来说,美是需要巨大成本的,在成本有限时,你需要优先完成你的游戏性而不是美术风格。

美是模式识别+意义共鸣,模式识别就和代码里的一样,是你对美的语言的认同,是普通的形式美,基础美的描述语言,比如网红脸,形式美、可以被模式识别的东西是很容易被复制粘贴的,意义共鸣则是这个东西背后的意义,比如8090后对于红白机的怀旧感,战争,苦难等等各种事件,这个东西就是意义共鸣,这个东西没有相同经历或者体验,很难被复制粘贴。

这两者可以互补,当某一项过强时,他就可以约等于美,而忽略另外一边。

发型设计核心逻辑解析

以发型修剪为例,解释说明为什么发型是提拉进行修剪。层次感,轻盈、蓬松、空气的感觉

理发大概有两种流派,沙宣和日式,沙宣可以理解为基础的理发技术,日式可能会上升到一点艺术的形式。很少有听说理发里怎么怎么艺术,怎么怎么大师,大家更多的感觉是理发更接近一个技术或者工匠,而不是艺术本身,再加上理发界本身科学素养不太够,那么他们在总结或者教授技术的时候,就自然的会选择贴靠一部分成熟的现代艺术理论,从而使自己说的东西更有说服度。理发选择了建筑学的包豪斯,但是实际上只能说是生搬硬套,为了靠而靠。

理发两个技术方向,线条、渐层堆积,线条就是塑造发型轮廓,渐层堆积可以认为是塑造体积,二者都类似绘画最终塑造了发型的纹理感或者是体积感。

头发的点线面那就更是直接硬套绘画的内容,有点关系,但是只能是表面关系。

植村隆博,其实就是把头发套到了空间中,把空间中的旋转、平移、拼接这么一套东西,挪到了人头上,真的有啥技术含量嘛?其实没有,如果你是个建模的,一秒钟就理解在他干啥了。唯一有点艺术感的地方就是空间关系的平衡,这个是需要参考之前雕塑、平面等方面的设计,他们在空间上或者视觉上有一种平衡感,同样的打破平衡也是一种艺术的突破。唯伟讲了半天就是吹了半天他的旋转、平移,现在看起来属实一般。

后续就是理发界的度量衡,通过一套理论总算可以量化各种发型了。这种也只能说是符合一般科学了,不再是凭感觉或者手感来控制发型修剪样式了。

再接着就是把用户进行分类,同时对发型和感官进行分类,从而更好的去满足用户需求,这样一套东西更偏向工程化,理发的工程化,这里艺术性就相对偏弱了。

准确说这一堂课大概说了一下发型逻辑的进步历史,但是也仅限于工程化就停止了,再往后如何艺术化,其实根本没有提,连入门都算不上。

穿搭设计核心逻辑解析

穿搭的核心,还是求偶,不过这种观点过于偏激了。但是穿搭的本质除了生存以外,其他不就是为了彰显个性嘛,彰显个性干什么呢,求偶,求偶是大头,所以这里说了男性穿搭本质上,也是为了求偶,女性那就是魅男了,底层逻辑确实如此。男性对于这一方面关注不多,本质上男性掌握着社会的财富和权力,所以男性多数是走向内在修养和财富实力的,这方面有压倒性的魅力。

男性穿搭第一课不是穿搭,而是健身。意见挺好,但是太绝对了。

衣服图案可以部分抵消身材肥胖,用图案材质,削弱体积感

清爽感或者利落感的搭配,一开始就要选好或者认识清楚自己的身材比例,然后进行穿搭,男士的穿搭说的比较简单,而且也是一些比较简单粗暴的逻辑。

到女士这里就说明了穿搭的核心,利用穿搭,刻意营造视错觉,修饰自身的缺陷,从而让自己符合当下的审美,但是这样并不会改变你的本质,你该是啥样的身材,该是啥样的脸,还是啥样,等于化妆。

对于男性审美,本质上还是从古希腊时期的审美经验延续至今;而女性审美则比较复杂,与时尚、明星、健美等等都有关系。

穿搭三要素:脸型、五官轮廓判断,量感判断,身型判断

五官这个好判断,轮廓一眼就能看出来,这里的量感比较复杂,量感简单说就是可爱风和欧美风的一个衡量标准,五官越立体、越分散,就越欧美范一些,量感就更大。

同理身体的量感是和自己的肩宽比例,穿搭的核心逻辑就变成了量感匹配,只要你认识到自己是什么样的,然后搭配对应量感的服装或者修饰即可,这样就能把你从丑拉到中位线上了,拉到美,那还是得看底子。

这里的穿搭理解只能说是初级的,还是以顺应自己为主,但是实际上穿搭有很多故意做碰撞、矛盾、冲突的元素,故意使用不和谐的内容搭配在一起,这种突破性的妆造也是有的,所以能看到有些明星会穿一些不符合自身条件的衣服,就是为了彰显他能突破某些固有的思路或者典型的造型,让人审美不会疲劳,其实和艺术中的视觉经验,视觉疲劳是一模一样的,但是唯伟这里讲得太浅显了

字体出版设计史

这节课就很无语了,字体设计史直接从AI生成了,没有自己去做考究、研学了。还好之前关注过一段时间的oooooohmygosh,有一定的字体设计的基础,不然就被忽悠了。

字体设计师有能力,也懂现代设计,但是缺少对于书法的理解(这是必然的,如果书法理解很到位,一般也很少去做平面或者字体设计,过于小众了),导致设计师只能从字体中找规律,寻求和总结美感,当然了真正的书法大师自己也无法总结出来自己美的规律,各自追求的艺术性都太强了,没办法适配这种通用性的字体的。把原本写出来的字转到显示器上去显示,就免不了要西化、或者说现代化,必须要把字体几何化,这种几何化又必须统一,否则这个字体风格就会变来变去的。

或者说字体设计师很像AI,他只能基于以前有的东西去再创作、再设计,本质上更像是一种风格化,设计师很难创造出新的东西了,字体的骨架或者大致样子都被古人定死了,你再造一个新的,让别人理解,这个难度很大。其次,中文字体的单字又非常多,而字体本身的版权、推广、商用又不够发达,这就导致做这个东西很容易亏本,甚至倒贴,它的经济性太差了。

但是这里面有一个小的分支,我比较熟悉的点阵字体,这个领域是比普通的字更专业的,需要更多更抽象的能力,有时候可能就是在造字了,而不是风格化,这种极简情况下的设计逻辑是值得研究,也是有一点商业价值的。

然后这里唯伟介绍的内容也是有问题的,他看到的都是高清的字或者说不讲究分辨率的字,都是艺术字,不是给程序、编码看的,但是实际上在屏幕上显示的字用的像素点本身要少的多,这里面讲究就很多了,他完全没展开这部分内容。他说的字体基本都可以认为是拿到产品包装、海报等等上面的装饰字,本质上距离我们说的或者使用的字体是不一样的,他这里完全搞混了这两个,还在拿这二者对比,其实是不对的。

后续是讲述字体有情绪,除了字体本身,还表达了一些内容,利用字体和他的背景空间,就能创造出超过字体本身的含义表达。

好的设计是感受和情绪积累的释放,后续举例的设计案例中,就很明显可以看到这一点,基本功是必不可少的,但是真正好的设计恰巧就是你日积月累,对于生活或者什么东西的仔细观察和思考,刚好和这个设计需求契合在一起了,这个设计自然就好了,同样的硬憋一个设计就很难,那种生搬硬套感会特别重。

大小错觉、骨错觉、断裂错觉,应该还有一些其他视错觉经验可以借鉴,这部分就算作基本素养了。

包装设计核心逻辑解析

包装重点在信息整理和阅读顺序,换句话说它的终极目的是为销量负责。可能它会曲里拐弯地影响营销或者其他东西,但终极目的总是销量,华与华恰巧就擅长干这个事情。

后续对于包装设计是否好的推论上就有点牵强了,你又要卖的好,又要包装设计的有艺术感?这本书就不可能,包装是给消费者看的,消费者的平均水平必须是大于等于这个产品包装设计的审美水平的,你艺术性超级高,包装是好看了,但是别人不理解你这是啥东西的时候,那就本末倒置了。但是这也要分割来说,如果一个商品是需要被普通人通过在超市、在线上这种盲选,那么这个东西大概率不是什么多高端的东西,现在信息如此丰富的情况下,推广这么强的情况下,人去盲选的概率很低了。但是反过来,当年信息不发达的时候,信息差比较高的时候,辅助盲选是很重要的。

那么包装设计的艺术美感就只能放在奢侈品或者小众性产品上了,比如手工艺品,或者一些个人开的店。个人意识比较强时才有可能出现所谓的“好包装”。在足够高的价格里,除了包装以外的宣发通常远超过包装本身的销量价值,所以此时奢侈品包装就必须能用来区分阶级,必须让人眼前一亮。

后续课程中的这些东西其实都能找到,我都见过好几次视频在介绍这种我们习以为常的东西是怎么产生、改良并最终变成今天这样的。只是唯伟纠结于 AI,陷入其中,忘记了基础搜索能力。

唯伟最后想说的是包装设计中的艺术性,他认为这才是好的,不能苟同,从历史来说,这种东西很难留在这个时代里,能留下的,只能是契合了时代本身浪潮的东西,就比如他以前讲到的例子,书法的石碑,工匠你是找不到的,但是碑文被传承了,同样的包装里的设计师很难被留名,但是如果你的作品足够出色,那么这东西一定会被留着包装历史中,但是不一定会被留着艺术史中。

中国古代陶瓷审美发展规律

陶瓷,这课开始双口相声了,但是吧,认知还是有点问题。考古和鉴定我之前也关注过一些,大概也了解了个七七八八。

这堂课前面就有点像两个半桶水的人,对着不了解的东西硬夸、硬聊,我听着都尬住了。

实际上陶瓷不像艺术,陶瓷的历史还是比较完整的,而且国内也有景德镇陶瓷大学,是有正儿八经的专业课的,陶瓷艺术设计专业,这部分过往历史直接看教科书就行了,它不仅解决艺术性的问题,还教手动技艺。

现在工业发展实际上还需要基础材料学研究配合才行。其次烧窑、窑温控制、热工艺这些也都要懂,而不能像古人一样无脑试错、全凭感觉。现在理论知识体系已经比较完备了。

宋代是中国陶瓷美学的最高峰,追求极简、自然、含蓄,其他朝代的艺术有点走偏了,宋代是最符合中国传统审美的,影响也是最大的。

中国现代陶瓷设计趋势报告

大概十年前去过景德镇,也看过当地的烧窑,还是有很多很多小作坊的,以前去的时候已经取消了很多小作坊了,容易引起事故,但是实际上在当地还是家家户户一个柴窑,当地景德镇的学生,在景飘,就是和主家合作,租用柴窑,烧制自己网上接单的定制瓷器,主要是定制图案,然后烧,基本都是手工活。

这节课也是个相声,趋势?就说那么一点,不做任何分析,全凭感觉,有点不靠谱。分析结论是没有数据支撑的,趋势也只是看到了什么说什么,没看到?那就不知道了。

趋势就是一个 IP 化,没了,真的有点招笑了。IP 不 IP 从来不是乙方可以决定的,那是甲方的事情,入不了甲方的眼,怎么 IP 化?其次,IP 化需要标准化或规则化的制品,手工谈什么 IP 化,产品素质参差不齐,根本没法产品化。这里面的难度和要考量的点多得多。日本或者已经 IP 化的一部分产品,本身走的也是批量工艺而不是手工,那就注定了做手工的不太可能大规模发展起来,只能一直处于这个灰度或者存量市场里。我十年前去他们就这么做了,十年后还是这样,真的进步了吗?真的有更多需求或者发展的未来给他们吗?

中国潮玩公司设计研究报告

这节课的部分结论和我之前说的一样,自己的IP是很难推起来的,大IP才可能在DIY或者艺术领域有所发展

这里看研报有点问题,唯伟和唯风都觉得看研报就知道这个公司在干啥了,其实不太对,这个研报本身就是美化以后的东西,谁敢让人真的揭露内部的一些有问题的地方,很多地方都被委婉或者处理过了,这个就涉及到另外一层了,万一说了一个特别差的报告,那这里就是做空了,甚至有其他意图的。大部分这种尽调或者研报都是偏向好的,做多的一面,所以这些企业的估值是一路上涨的,给市场信心,让投资人更愿意去投。

泡泡马特的前人或者模仿对象,三丽鸥,说是泡泡马特没有自己的IP,其实只是他的IP建立渠道和传统的动画、动漫、小说不一样,他没有这一块的资源,所以他从另外一个角度出发,从当代人的需求触发,给了Z世代一个精神寄托的IP,当这个东西突然起来了以后,后续这个IP自然而然就会向其他领域进军,寻求更广泛的市场。但是总体来说还是要先建立一个爆款,然后维持住这个爆款的热度,才能有后续的发展。IP消费是为了满足情感需求?你怕不是被消费主义洗脑了

万代发展规律解析

感觉这几节课都有点水,就是给你介绍一下历史,国内的竞争者,但是对于他们的东西,这里的艺术性的分析,少得可怜,只有宏观分析,这玩意我还需要你来讲吗,我直接AI问一下得到的不比你还准确。

这节课唯一得到的内容大概就是知道了国内的布鲁可、铜师傅、INART,这三个公司,但是对于他们的说法和我的体感也有点出入。铜师傅的线下店,有点人逛,但是真正交易的很少很少,同样的这个铜师傅有n多工艺品或者类似名字的招牌或者店,这类的艺术品看似火爆,实则线下遇冷?

但是总体趋势还是显而易见的,你得有这个商业中的源头,IP,没有IP或者不能产生IP,那么你的持续发展的动力或者源头就有问题,但是这玩意对于普通艺术创作者有啥用?有多少人能打造出来自己的IP?

设计大历史

早期设计上的装饰品或者奢侈品总是繁复为主,简略的很少,能被保留下来的大概率都是过去的贵族,有钱人的东西,而这些人为了体现本身的阶级不同,这种加法性质的设计就能把生产力、权力凝结到这个器物上,从而体现他的不同。

这里或者之前的课程中唯伟和唯风都说过现在是由实转需的一个年代,这个论断还是有点早了,他们没有真的体验过VR,只是理解了这个产业里吹嘘出来的概念,不知道是什么制约了这个东西的发展。这个发展不是说你内容填充了,这个产业就有了,其实这个是背后的总体大社会或者说主流社会,主流阶级的水平上来了才有那么一点点可能。为啥是可能,因为这个产业里还有很多东西无法突破,这部分内容被基础科学制约了,电池、材料、物理,这部分基础科学无法突飞猛进,对应的产生或者生产力是无法转嫁到VR上的,即使基础科学突破了,那距离被应用到VR这种超级小领域上,也是要很久很久的。

过早的预判你是得不到正确结论的,刚好最近看过了一个大佬的记录,他16年就想着用AI来写代码了。16年,我才毕业,那会我连AI是啥我都不知道,我都没法想象AI能帮我写代码。他在当年就折腾了一下,但是实际效果就是达不到预期,这不仅仅是当年算法不够强,更是当年的算力,当年的成本比现在高的离谱。AI能写代码只是巧合,AI的底子是文本类型的,代码恰好也是,这只是他恰好的一个应用方向,但是你站在10年前你说你要做这么一个东西,少了这个大模型的基础,这事不可为,当年没有投资人相信他能成,也是正确的。

某些时候用户体验成立的根本是用户数量足够大,这种改进可以创造足够大的价值,数量过小的时候,这个体验的价值也会非常小

这节课的核心主要是介绍俞军-产品方法论,但是没想到最后的结论是反过来的,让大家学习一下反着做,这里有点新意吧。不过这些做产品的,多少都有点偏执,有点过度自信,特别是自己列公式啥的,一看就是初期产品干的事情,必须要找一点东西证明一下自己的理论是否正确。俞军实际在滴滴中更多的是建立和完善产品职能部门和一些分歧决策,实际上他本身对于业务线的接入应该是比较少的,大部分还是其他产品干的。至于视频里说的产品被拿去干这干那了,俞军要么是只当打地基的人,不理解业务,所以他不知道,要么就是他知道,但是他也无能为力,单纯进去的产品管理者,要服众还是非常困难的,特别是中间空降的。

最后落点还是回到了艺术家的小众市场,前面铺垫了这么多,最后落回了这里,我只能说这个前奏也太长了。讲了那么多瓷器、潮玩、设计发展史,实际上都是再说大众化的是什么样,而艺术家你大概率是走不了大众化的,因为如果你走大众化早就已经功成名就了,而不是还在这里听他讲课。

小众市场,本质上就是去满足那部分偏执或者观点特殊的人群,当决定了做小众市场的时候,就要放弃掉之前那些大众市场里的公约数或者说偏见。做高端,做小众,追求一种高价值的健康状态,放弃对无限增长的执念,接受市场的天花板,不做既要又要的事情。

  • 这节课最有价值的差不多就是这一句了,放弃无限增长,增长这种东西就是资本最爱听的故事,但是对于艺术家来说却不一定是。

小众市场天然就是在马斯洛需求层次理论的上三层。

艺术从业者有很强的艺术表达需求,这种东西很难被大众理解,天生就适合小众化的。当然任何小众化的东西如果你的市场是全人类或者小一点,全体男性或者什么的,算下来可能都是很大的一个范围。

独立游戏人生存指南

以星露谷物语为例,巧了,这个项目我也非常了解,能做成这样的游戏,真的是非常难得一见的,独立游戏是在变好,但是不值得所有人去投入,他的市场规模还是太小了,他的受众就是我这种喜欢一个个精巧构思的人,但是独立游戏都有一个大问题,他不是适合所有人的,他的天花板非常有限,肉眼可见的,能做成星露谷这种行业顶级,把任何3A大作都能吊起来打的,这是奇迹级别,学习的意义真的不大。

第二个游戏,幸运猎人,巧了,我刚好玩过,而且还是demo,后续没有进我的关注和愿望单,原因很简单,demo过于粗糙,无论是动画还是机制都有点烂,玩通以后我都没打卡第二次的兴趣。刚好这里说了他是怎么做的,果然美术是外包的便宜货,不过也有可能我玩的demo刚好是他改版之前的。这个游戏实际销量应该也比较小,游戏内容偏少,至于这个作者说的,有点过于美化了,他的这些机制,所谓的节奏,就是难度曲线,在我看来烂的一坨。就这样这个游戏上线以后没有暴死,不到五百的评论,维持在了特别好评,估计是后续没人了,如果有的话这个平均应该比这个低很多。这个游戏他找了发行方,后续可能看到有点安卓手游的潜力,所以改去安卓再发行了,然后就变成了看广告收费?道具收费的游戏了

第三个游戏,偃武,有点无语,就这游戏,这流量,还点名率土之滨的改良,差的不是一点半点。

image-20260327004127484

这游戏我都搜了半天,他的宣发在哪里啊,19年上线到现在都七年了,竟然还能活着,只能说舔大哥是真牛逼。听了半天,这游戏是买量的,就是牛皮癣小广告,我吐了,买量刚好懂一点点,这里面门道可深了,买量那个广告设计那可是都是靠钱砸出来的,能让你看到的广告基本都是钱砸出来的,从N多广告设计中留存下来的,确实是有一定的参考价值,但是你要说你对标这玩意做用户需求,我就有点无语了。听唯伟尬吹这个项目我是真的有点难受,有没有可能你觉得牛逼的地方,在大厂这都是基操中的基操了。

第四个游戏,烽火与炊烟,这个我也是被画风吸引的,游戏CG,吹得很厉害,今年就要发售了,但是这个游戏的可玩性到底在哪里,目前我还没看出来,由于游戏包含要素过多,他是不是好玩,很难评断出来,目前只能说体验有点新奇,还谈不上好玩。对应这个游戏的3D版本你可以认为是燕云十六州,他要能做到燕云这样的话,也能保住名声。根据采访稿,这个团队是新人,经验不是很足的那种,虽然夫妻俩做了4年多,然后拉了五十多人又弄了2年多?但是最终品质能否达到预期,我还是先怀疑一下。

最后是吹哥的游戏观点分享,挺好的,吹哥的观点和我很相似。

Summary

总体来说这个课不值得买,过于通识向了,只是对他之前 2 个课内容的一点补充。主要问题是讲得太浅,和他适配介绍的方法论比起来差距有点大。只能说讲了几个案例和可能的方向,至于方法论,我只能说都是常识与基操。只有你不是这个领域、也从来不关注这方面事情的人,才能有所收获。但凡你是想做这个方向或者想深入探索,这些内容大概率在平常生活里就已经接触到了,能听下来的我估计只有小白了。

艺术与审美,本来以为他是可以从新手的角度介绍并引导你入门的,没想到后面的内容完全是艺术家找不到门路了,于是去探索其他方向了,然后从其他方向把方法论或者什么东西借鉴过来再引回艺术界,是唯伟自身经历的一个中期总结吧,类似在布道自己的观点和方法论,到这里基本就是他目前学习经历的一切了。

最有用的大概就是第一阶段的课了。

Quote

https://mp.weixin.qq.com/s/cFCXRR-YtlO-3kXpVM23sw

7.1环绕立体声COLOGCS音响测评

2026-03-24 00:00:00

Foreword

25年换的新主机,配的AOC显示器,这个显示器设计有点问题,它的音响在屏幕后、显示器支架的左右两侧,发声位置很奇怪。特别是当显示器不靠墙的时候,声音非常空洞,要听清就得开比较大的声音,再加上我又有点聋,用起来很难受。一直想弄个音响,但是没看到适合的,再加上又有一些环境问题,也不方便弄大的、多声道的,没想到意外刷到了一个视频,发现了一个有趣的产品。

COLOGCS

https://www.cololight.com/

https://www.indiegogo.com/zh/projects/cololight/colo-gcs-the-world-s-first-7-1-2ch-speakers-for-pc/

这个项目最初是24年 Indiegogo 的众筹项目,成功了,没想到卖得还行,最近又出了新一代,稍微改进了一点。

image-20260324001232138

第一个版本它是给电竞椅或者赛车桶椅做的,后来发现人体工学椅更多,于是又出了人体工学椅的适配版本。记得很久以前有人提过类似的产品或者DIY设计,但真正产品化的还真没有,COLOGCS确实是脑洞大开的产品,很有意思。

现在已经出到2代了,安装支架更新换代了,音响本身没啥大变化。这个形态的音响目前市面上只有这一款,找不到任何竞品,其他电竞音响或者 soundbar 之类的东西也没法跟它对比。

这玩意大得超乎想象,跟34寸显示器箱子差不多。实际开箱比较简单,就是主体 + 2个天空声道 + 若干线材和收纳捆带。

安装

安装视频,安装以后大概就是这样:

image-20260324001124086

image-20260324003154198

安装以后主要就是电源线和 HDMI 线。HDMI 线需要连到显卡的 HDMI 接口上,这时发现我显卡只有一个 HDMI,导致要连电视就只能走 DP 了。

  • 自带了理线带,自带了HDMI转角头

要使用全景声,不需要装任何驱动,但是要安装杜比全景声的软件。

https://www.microsoft.com/zh-cn/p/dolby-access/9n0866fs04w8

安装以后只需要开启家庭影院杜比全景声,再打开上混器就行了。

image-20260324001646578

接着随便打开一个杜比音效的视频,就能听到效果了,非常震撼。

COLOGCS 本身也支持蓝牙模式,玩手机也能用,同时它也有麦克风接口:左侧是蓝牙模式的麦克风接入口,右侧是 HDMI 模式下的麦克风接入口。但是这个东西有个小问题,出声和麦克风在同一侧,这会导致麦克有回音而且很难消除,所以麦克风要怎么搭配它一起使用,还得找个解决方案。

缺点

image-20260324005045325

虽然是7.1声道,但是缺少从人正前面传过来的声音,这个有点遗憾。如果能再利用一下原本的显示器或者音响,做个结合,就更好了。

固定的稳固程度还是有点松垮,整体结构太大了,你晃动椅子,他也跟着晃动,好处是你躺平,音响也跟随你躺平。

不同椅子的适配性不同,导致有可能你并不处于最好的声位,这个没有调节的可能,软调节也不行。

声音结像差一些,问题其实就是上面这些因素造成的。

便捷性差了点,还是挂了一根线,椅子不能无限转圈,会导致线缠绕到下面,平常用不乱转圈其实还是挺无感的。

有时候会偶尔出现没声音的情况,但是重新切一下声音又好了,有点像休眠,比较难复现。

Summary

对比显示器音响,音响就在耳边,确实很爽,不用开很大声音就听得很清楚,还有一些小问题,不是所有游戏都支持杜比全景声,还是挑游戏的。

在车展碰到的赛车模拟装备,基本全都配了 COLOGCS。就算日后 PC 这边淘汰了,也能转给模拟器的赛车桶椅使用,也不错。

烟花三月下扬州

2026-03-09 00:00:00

Foreword

“烟花三月下扬州”,但我去的是公历 3 月,真不算个合适的日子,如果是四五月份来,体验应该会好不少。

扬州

三月的扬州温度比较低,大概 2–10℃,也就只有短暂的中午有阳光洒在身上时会觉得暖和一点。稍微来点小风就又冷飕飕的了,我是不会说主要是因为我穿得太少了,从深圳过来没带厚衣服,确实有点扛不住江南的阴冷。

扬州整体都是青砖石瓦的矮房子,很少高楼大厦那种现代建筑,街边随处可见的亭台楼阁,确实很江南。再加上这里文人墨客众多,稍微走几步就是某某的故居、某某的纪念馆。地处江南,冬天不会下雪,树木也都是绿色的,很是诗情画意。

文昌阁

刚好住在文昌阁附近,走路大概就五六十米的样子。文昌阁不大,也不高,而且在路中央,正常没办法走过去,不过这条路不一般。

image-20260309193435393

国庆路,但是车道让位给了假山园林。是的,你没听错,故意抹去了两三个车道留给假山园林。基本从这里走过去就到了扬州的核心景点附近,算是景点入口了。

image-20260309193936688

往前再走一点,路中心还有一个亭子,这个可以走过去——四望亭。比较有名的饭店怡园就在亭子后,以早茶出名,没想到广式早茶,扬州也有。

image-20260309194031495

东关街

东关街是一大片老街区,就是印象中的江南水乡的样子,青砖石瓦。街道两旁免不了被商业化,但是从我的感觉来说,比深圳或者重庆等地方的古镇还是要好一些的。商业化程度不是那么高,不是千篇一律的臭豆腐、大鱿鱼、内蒙烤串,又或是各种本地特产、奶茶。这里的街区本质上还是为本地人服务的,并不会因为是景区就提价、专宰游客。

我是淡季加工作日去的,平日里人也不少,不过采耳、洗脚店的密度那是真的高,走几步就有一家。

image-20260309195046669

八戒烤猪蹄是家网红店,刚好前面也出了点舆情,我去的时候基本没人。

刚开始还没感觉扬州有多少桥、多少河流,后面就发现那是真的多啊。

image-20260309194203790

逸圃

逸圃,门票 15,建议不要去。地方很小,其实就是以前有钱地主家的宅子。要说考究,真的一般,传统的风水、园林、造景我认为都有点不及格。

个园

image-20260309195348760

个园,门票 45。咋说呢,还是有点小,前面的祖屋没啥看头,主要看点是园林,园林这部分相对比较大,造景比逸圃强多了,能看出一点点文人雅士的情调了。

旺季会有千秋粉黛表演,大概就是吴侬软语、弹唱之类的表演

瘦西湖

瘦西湖算是扬州最有名的景点了。我之前一直以为瘦西湖是小一点的西湖,实际上根本没啥“完整的大湖”,反倒是很多支流。古人的“二十四桥明月夜”,说的就是这里。

瘦西湖挺大,一般都是南门进来,但是我从北门进的,北门人比较少。越往南走人越多,实际很少人全逛完,大部分人半途就走了。

还好北门人少,下车的时候,小广场有个小栅栏,大概也就15cm高,一边回消息一边走路,脑子一抽遇到栅栏跳了一下,然后就被绊倒了,摔了个狗吃屎,整个人扑到在地上,超级尴尬,还好手机啥的没摔飞,就是身上一堆擦伤

image-20260309195904973

image-20260309200525258

亭台楼榭

image-20260309200620746

瘦西湖有很多黑天鹅、绿头鸭,游客拔草喂,它们也吃,都是野生的,在扬州过冬。

image-20260309200551697

柳岸垂荫

image-20260309201643851

这时刚好是梅花开放的时节,其他花基本都谢了,整个瘦西湖是比较清新、素雅的

image-20260309201823676

瘦西湖的商业化也是比较克制的,也有可能是淡季,出摊比较少。至少北门进来基本没啥商业化,走到南门附近才会多一些。也不像一些景区把摊位、各种大招牌都挂在景区内,风格很突兀。

在茶馆里,坐在窗边,喝个茶,看个景,还是挺不错的,单人茶价格也不是太逆天。

大运河博物馆

大运河博物馆需要预约,周一不开门。预约要提前好几天,否则根本约不到。淡季周末提前一天都不行,得提前 2 天。我是跑过去才发现进不去,很是尴尬,运营没有照顾线下的人群,没有线下票,这点就很难受。

围绕博物馆建起了一圈私房菜馆,周边也是水路区隔,整体建设也是仿照二十四桥的概念来的,什么铁索桥,木桥,赵州桥,每座桥的设计都不一样。

宋夹城

image-20260309202804774

宋夹城就在瘦西湖边上,可以认为是个大型体育公园,是一座小围城,免费,主要是给本地人开放散步的。边上是绕城步道,凉风习习。

皮市街

对比东关街,皮市街明显就是那种重度商业化以后的游客街了。皮市街稍微往前走一点就是何园,何园类似个园,感觉千篇一律,所以这次就没去了。

江泽民故居

这里也需要预约,只能在外面看看,没啥特殊的,就是个小宅子而已。

吃喝

扬州,淮扬菜,没啥特别的,烧饼挺多的,倒是挺好吃的。吃了一家很小的淮南牛肉汤,那个饼是真的脆,很多层,一口下去疯狂掉渣。

本地有名的饭店就是大毛,不过吃的内容一般般吧,没感觉有多特色,鱼头泡油条比较少见,味道嘛,我只记得油条比较好吃了。

扬州炒饭,我感觉不如纯蛋炒饭好吃,很普通,没啥香味,也没啥怪味,说是扬州刚好是苏南苏北中间,所以比较中庸一些,不甜。

Summary

扬州古城小巷里依然有很多人还住在这里,看起来是翻新过,但是并不是那种推倒重建的翻新,旧的东西还在。不像广东这边,古建筑基本给干没了,翻新以后直接没人住,都变成商业化的门面,翻新风格还是全国统一,真的没意思。

作为古城,人文气息确实丰富,很多东西小而精致,城建规划时依然保留了老城的精气神,很是难得,下次有机会去苏州再对比一下。