跳到主要內容

009互動

沒有人排班的城市:300 個小人各忙各的

一座程序化生成的 3D 城市,每個小人只看自己的四條需求決定下一件事。把決策換成隨機或固定時程,看整座城市的節奏怎麼變。

發布
閱讀時間
9 分鐘

上一篇只有一台車。這一篇有三百個人,住在同一座城市裡,各自上班、吃飯、找人聊天、回家睡覺。

先看。城市裡的一秒是外面的十分鐘,所以兩分半鐘就是一天。點畫面裡任何一個小人,或按「跟著一個人走」,鏡頭會降到街上跟著他。

fig 01/city / day

載入場景…

  • 工作
  • 吃飯
  • 社交
  • 回家
  • 沒事
城市時間
Day 1 06:00
每幀耗時
0.0ms · CPU
300 個小人(手機上是 100 個)。顏色是他正在做、或正要去做的事。天色、路燈和窗戶的燈都跟著城市裡的時鐘走。

早上八九點,青色的點從住宅區往市中心的高樓聚過去;傍晚散開;半夜街上只剩零星幾個人,窗戶一棟一棟暗下來。

這座城市裡沒有時刻表。程式裡找不到「八點上班」這一行。每個小人只知道自己的四個數字,和一條算分數的式子。

先說這不是什麼:這裡沒有機器學習。 沒有訓練、沒有神經網路。這是遊戲業用了二十幾年的老辦法,叫 Utility AI(效用式 AI),《模擬市民》裡的人就是這樣決定要去冰箱還是去睡覺的。它值得一篇文章,是因為它很小:算分數和挑選的程式不到三十行,卻足夠讓一座城市看起來有作息。全部的模擬大約九百行 TypeScript,沒有用物理引擎,也沒有任何外部素材。

城市是怎麼長出來的

整座城市由一個數字(種子)決定,同一個種子永遠長出同一座城市。

先畫 8 × 8 的街區,每四條路有一條是比較寬的主幹道。最東邊一排是河岸。其他街區看它離市中心多遠:近的是商業區,再外一圈是餐飲街和一些辦公樓,最外面是住宅區,中間隨機留幾塊公園(種子 1 是兩塊)。建築的高度是「一個隨機數 × 離市中心有多近」,所以中間高、邊緣矮。種子 1 長出來的是 373 棟建築:248 棟住家、29 棟辦公樓、96 間店面,最高的 80 公尺。

小人只走人行道和斑馬線。每個街區外圍一圈人行道,轉角和隔壁街區用斑馬線接起來,每棟建築在人行道上有一個門口。這些點和線就是一張圖:639 個節點、863 條邊。要去哪裡,就在這張圖上用 A* 找最短路;同一對門口的路線會被記下來重複使用。

四條需求

每個小人身上有四個從 0 到 1 的數字,0 是滿足,1 是受不了了。它們隨時間往上長;去對的地方做對的事,會往下掉。

需求每小時長多少過了多少才會考慮去哪裡每小時降多少
疲勞0.060.75自己家0.18
飢餓0.0750.6餐飲街的任何一間店0.9
社交0.050.7公園或河岸0.4
待辦事項0.15(只在 616 時)0.4自己的辦公室0.25

每個人生出來的時候,這些成長速度會各自偏移最多 20%,前三條的起點也是隨機的。

下面是其中一個人的三天,由你的瀏覽器現場算出來。

fig 02/city / needs
疲勞飢餓社交待辦
  • 回家
  • 吃飯
  • 社交
  • 工作
  • 走路
  • 門檻
四條線是四條需求,虛線是門檻;深色的區段是晚上。最下面的色帶是他當時在做什麼。取消「待辦事項」,看他的一天空出多少。

飢餓是最規律的一條:八小時長到 0.6,吃四十分鐘,一天三餐。待辦事項是四條裡唯一不屬於身體的,它是桌上還沒做完的工作:白天工作一直進來,它就一直漲,只有人在辦公室裡才消得掉;16 點以後不再有新的進來,做完就能走。把它關掉,等於這座城市沒有人需要工作。

疲勞那一條多了一個東西:它晚上長得比較快。 成長速度要再乘上 (1 − 0.9 × 太陽高度),太陽越低、人越容易累。少了這一項會怎樣,我量過:300 個人的睡覺時間會平均攤在一整天,任何時刻都有兩成到四成五的人在睡,這座城市就沒有夜晚。加上去之後,凌晨一點有三分之二的人在睡,中午只剩 2%。

怎麼挑下一件事

每一件可以做的事有一個分數:

效用 = 需求² − 0.1 × 走過去要幾小時

平方讓越急的事越不成比例地重要:0.9 的餓不是 0.6 的餓的一倍半,而是兩倍多。後面那一項是路程的代價。小人把過了門檻的事全部算一遍,挑分數最高的;一件都沒有,就待在原地沒事做。

fig 03/city / utility

選項 A勝出

效用0.573

選項 B

效用0.482

效用 = 需求² − 0.1 × 路程(小時)

兩個選項,各有一個需求和一段路。算分數的就是城市裡每個小人用的那個函數。

一開始 A 比較急但要走四十分鐘,B 沒那麼急但就在旁邊,A 贏(0.573 對 0.482)。把 B 的需求從 0.70 慢慢拉到 0.77,B 就反超了:七個百分點的急迫,抵得過三十五分鐘的路。在這座城市裡,出門一趟的中位數是 15 分鐘,所以路程通常只在兩件事差不多急的時候才有決定權,例如挑哪一間餐廳。

光有這條式子還不夠,有兩個地方會出事:

吃一口就走。 飢餓過了 0.6 才去吃,吃了半小時降到 0.6 以下,「吃飯」就不再是選項,於是他離開,幾分鐘後又餓了。解法是兩個門檻:過了 0.6 才開始,降到 0.05 才算做完;正在做的事另外加 0.3 分,別的事要明顯更急才能打斷它。

所有人同一分鐘做決定。 小人不是每一幀都重新想。他們只在三個時候想:到了目的地、事情做完、或者每 30 分鐘一次。如果這 30 分鐘大家都從零點起算,三百個人會在同一分鐘一起出門,像有人在指揮。所以每個人的起算點是錯開的。

三種腦袋,同一雙腳

要知道作息是不是真的從需求裡長出來的,最好的辦法是把腦袋換掉,其他都不動:同一座城市、同樣 300 個人、同一套走路的程式。

  • 固定時程:所有人 8 點上班、12 點吃飯、18 點回家。
  • 各看各的需求:上一節那條式子。
  • 隨機:隨便挑一個地方去,待半小時到兩小時。
fig 04/city / modes

固定時程

0006121824

出發尖峰 · 最擠的 10 分鐘計算中…

每人每天出門

各看各的需求

0006121824

出發尖峰 · 最擠的 10 分鐘計算中…

每人每天出門

隨機

0006121824

出發尖峰 · 最擠的 10 分鐘計算中…

每人每天出門

  • 回家
  • 工作
  • 吃飯
  • 社交
  • 走路
  • 沒事
三種腦袋各跑兩天,由你的瀏覽器在背景算出(沒有畫面,所以很快)。圖是第二天:每 10 分鐘,各有多少比例的人在做什麼。

固定時程的一天是用刀切的。八點整,100% 的人在同一個十分鐘內出門;沒有人社交,因為時程表上沒有這一項,社交那條需求就一路頂在 1.0。

隨機的一天是平的,半夜三點和下午三點長得一樣。

中間那張有作息:夜裡大部分的人在睡,早上工作的青色慢慢漲上來,傍晚慢慢退掉,吃飯和社交散在整天。沒有人排這張班表,它是三百組稍微不一樣的數字各自算出來的結果。最擠的十分鐘只有 12% 的人同時出門,和隨機的 10% 差不多;但隨機沒有那個形狀。

把「待辦事項」整條關掉再量一次(離線量的:300 人、種子 1、跑五天取最後一天):睡覺、吃飯、社交的時間幾乎不變,空出來的時間從 11% 變成 40%。小人不會自己找事做,這是這個模型最誠實的地方:它只會回應你給它的需求。 想要他們買東西、運動,就得再加一條數字。

誰在看著所有人

三百個人各忙各的,要怎麼知道誰在做什麼?模擬每發生一件事就寫一筆記錄:誰、什麼時候、出發/抵達/開始/結束/沒事做、在哪裡。三百個人一天大約一萬一千筆。

下面是完整的工作台。右邊是總覽:每個人一列,現在在做什麼、去哪裡、四條需求各多高;點一列,鏡頭就去跟那個人。下面的事件流是把記錄寫成句子。所有旋鈕都在這裡:人數、種子、城市大小、三種腦袋、待辦事項的開關、小人互相閃避的力道。

fig 05/city / overseer

載入場景…

  • 工作
  • 吃飯
  • 社交
  • 回家
  • 沒事

點場景裡的小人可以跟拍;點空白處回到全景。

出發尖峰
0%
同步度
0%
每幀耗時
0.0ms · CPU
draw calls
0

過去 24 小時裡,最擠的 10 分鐘有多少比例的人同時出發

總覽

旋鈕

種子 1
時間軸可以往回拖。重播的畫面、每個人的狀態和需求條,全部只用事件記錄推回去,沒有重新模擬。粉紅色的小刻度是你改過規則的地方。

把時間軸往回拖。畫面回到那個時刻,但模擬並沒有倒帶重跑,那一刻是從記錄出來的,靠的是兩件事:

需求條。 兩筆事件之間,一個人的狀態不會變,所以每條需求都是一條直線。每筆事件順便記下他當時的四個數字,之後任何時刻的值就是「那個數字 + 速度 × 經過的時間」。

位置。 「出發」那一筆記了從哪裡出發、要去哪裡,路線可以重新找出來;再看他是幾點抵達的,就知道這一刻走到了路線的幾分之幾。重播不會重現小人之間互相閃避,所以位置會差一點:我量過,走在路上的人,重播的位置和當時真正的位置差的中位數是 6 個單位,大約是他走四十秒的距離。

記錄最多留 5000 筆,大約是三百個人的十個小時。更早的事件不是直接丟掉,而是先併進一張「記錄開始時的快照」,所以時間軸的起點永遠是一個已知的狀態。

畫面下方的「每幀耗時」是你的機器現場量的:每一幀裡模擬、面板和送出繪圖指令花了多少毫秒的 CPU,不含顯示卡畫圖的時間。在我的機器上(Apple M4 Pro),300 個人的城市往前走一分鐘要 0.095 毫秒。

和真的不一樣的地方、數字怎麼量的

這不是在模擬人。 四條需求是我選的,速度是我調到「看起來像一天」為止的。待辦事項從早上 6 點就開始累積,是為了讓它在八點半左右過門檻;真的上班族不是這樣運作的。這個模型能說的只有一件事:不用時刻表,只靠幾條會漲會退的數字和一條打分數的式子,群體的節奏就會自己出現。

走路是畫出來的。 小人的位置沿著路線前進,加上一點互相閃避,會被夾在人行道的範圍裡;彈跳和前傾只是繪圖。沒有車、沒有紅綠燈、沒有室內,做事的人就站在門口。小人畫成真人的兩倍大,不然從空中看只有兩個像素。

遊戲裡的版本多了什麼。 真的 Utility AI 每個選項會有好幾個考量,各自過一條曲線(不一定是平方)再相乘;物件會自己「廣告」它能滿足什麼;還有個性、記憶、排隊、預約。這裡留下的是最小的骨架。

數字的來源分三種。圖 02、03、04 和工作台上的讀數是你的瀏覽器現場算的;文中標明「我量過」的是離線跑的,種子、人數和腳本都在 docs/research/city-of-agents/;城市的棟數和節點數是種子 1、8 × 8 的生成結果,有測試保證同一個種子永遠一樣。

來源

  • Utility AI 的完整做法:Dave Mark, Behavioral Mathematics for Game AI, 2009。
  • 《模擬市民》的需求與物件廣告:Kenneth Forbus、Will Wright, Some notes on programming objects in The Sims, 2001。
  • seek、arrive、separation:Craig Reynolds, Steering Behaviors For Autonomous Characters, GDC 1999。
  • A*:Hart, Nilsson, Raphael, A Formal Basis for the Heuristic Determination of Minimum Cost Paths, 1968。