From e82ff0b79e4573995170f4c76918d954ec84c756 Mon Sep 17 00:00:00 2001 From: yallex Date: Sun, 9 Aug 2026 07:36:00 +0300 Subject: [PATCH 1/4] Give each vehicle a camera, and a way to see the world without ArduPilot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SITL vehicles flew through a world nobody could look at. The video feed in SkyHub was a canned MP4, so a survey flown in the simulator came back with footage of somewhere else entirely. This draws the world the physics already knows about. A software rasteriser runs on its own thread with its own copy of the world, cuts the screen into horizontal bands with one thread owning each — no locks in the inner loop — and serves each vehicle a JPEG or MJPEG stream off the control plane at /vehicles/{id}/camera.mjpg. 0.67 ms/frame at 256x144 on four threads. Rendering started out on the tick thread and starved the physics: one row of pixels is about a whole 1.25 ms budget at 400 Hz, and the autopilot refused to arm. Hence the separate thread and the separate world. The ray caster it replaces is kept as the test oracle. Both renderers now take "ground or building" from which body the geometry came from rather than from which way the surface faces — a flat roof's normal points straight up exactly like a field's, so the old heuristic grew grass on every rooftop in the city. Triangles are also culled against their far edge rather than their centroid, which stops buildings shedding individual triangles as they cross the draw distance and standing there with holes in them. tools/getting_started.sh builds, cooks a 1.2 km demo city and renders it from four poses in about a second, with no autopilot, no network and no simulator running — you can see the thing work before committing to the half hour the ArduPilot build takes. With ARDUPILOT_ROOT set, --fly puts a vehicle in that city and serves its camera live. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_017cgM68QE3FDz7S2pQAfTaZ --- .github/assets/skysim-getting-started.jpg | Bin 0 -> 42247 bytes CMakeLists.txt | 31 ++- README.md | 27 +++ src/api/control_server.cpp | 132 ++++++++++++ src/api/control_server.h | 4 + src/core/world.cpp | 76 +++++++ src/core/world.h | 32 +++ src/main.cpp | 123 +++++++++++ src/render/camera.h | 80 +++++++ src/render/frame_store.h | 54 +++++ src/render/image.h | 21 ++ src/render/jpeg.cpp | 33 +++ src/render/jpeg.h | 20 ++ src/render/raster.cpp | 251 ++++++++++++++++++++++ src/render/raster.h | 95 ++++++++ src/render/render_service.cpp | 87 ++++++++ src/render/render_service.h | 85 ++++++++ src/render/renderer.cpp | 89 ++++++++ src/render/renderer.h | 48 +++++ src/render/shading.h | 65 ++++++ tests/test_render.cpp | 211 ++++++++++++++++++ tools/getting_started.sh | 150 +++++++++++++ tools/render_probe/main.cpp | 156 ++++++++++++++ 23 files changed, 1869 insertions(+), 1 deletion(-) create mode 100644 .github/assets/skysim-getting-started.jpg create mode 100644 src/render/camera.h create mode 100644 src/render/frame_store.h create mode 100644 src/render/image.h create mode 100644 src/render/jpeg.cpp create mode 100644 src/render/jpeg.h create mode 100644 src/render/raster.cpp create mode 100644 src/render/raster.h create mode 100644 src/render/render_service.cpp create mode 100644 src/render/render_service.h create mode 100644 src/render/renderer.cpp create mode 100644 src/render/renderer.h create mode 100644 src/render/shading.h create mode 100644 tests/test_render.cpp create mode 100755 tools/getting_started.sh create mode 100644 tools/render_probe/main.cpp diff --git a/.github/assets/skysim-getting-started.jpg b/.github/assets/skysim-getting-started.jpg new file mode 100644 index 0000000000000000000000000000000000000000..2740faea79f1521f850ff8b4d32830ed2f602241 GIT binary patch literal 42247 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

cS6 z`|Wo5XZO@k3cEpoZl{Vum;L7~j8ZKPC=l`0&nJ@bRExFrl)JG6i7b8G;2%A)g!B4+ z0Tx+iawzt={}dF}-grB?T8E$^vHUy^%cHxt0Q@GXt#w~p=-aK5)#=Gi3u3b-)7UPi zevp9@EX-TPLfQwu*q8QA1_)a)`tY zB=9wzm3@|_DrVy0yl)ZqQKOlW)#4fDlhCRw41Nl5Bj?@Qf`c!?W0bDWQHpD}( z4)Ra9F7^6rU;5*0p+W6_`N10`X8BAt@V#qr$-x^>dhT|Ndj%_mJ_ph4qAt~-`xkQO zSf{5@y_{?DlY0mskDZFZmZMq?Y{gfNeF^>GSGQx^G73uaSsF~;s$ed^L@T0vfFn6x3Di|p&Tr$-(SA7GsMNsNqnBaa9!9Ef9-wCso6P5 z9(|4r0N6i;>_uP8kjh(j5yS;0N!IG*uxz*d(q~YF(e>|r$2=%GYt0#w82g!df zk`*h;$_IB$RJO?RNLRitA33_!((7Vo4cwtgLp))jmm2YbjzQ%LQ4@OwZGOWN9h`O+K?J7$A!SeN)6v?!w`Pj*e;qq$8O9frBvL{K?mPKe%B0PobkwD|`2ui>V zd;H72)bKek6*uPC2H_Znw)`jEmRU9vsF$goKUa6lE2Sg0FA)WGXy~C%*MG zHwc9h062c9^4Ukq=>Q;1@#L+ztdgTIA<&3%{;5LV>bZO zfc_=5v&%JR&AsU7Wl}W7+r?Z&(DJKde&0oUx#o|(Mp=T2?u^nGj(6FB&{&GAb2HTj zpVBp-X#4fov~M4?4@L2}zqlE2LR#)jv@KYu?5^wT0Nh?;d)Vt!)r(wZ1^oiCObL2x z)>54hG;T307FWf-g_dU_y1CZe<*S{>y~DO3RQ94(8jz7<*iN9>Cl0lqey1IiYi4am zZwAsq8uAtJI$1$9J@XTg=gkc+I#ynIzyX3Ym*y~55Ot4#PKIk2iB^m8dhf?No1iMj_FtM=!?r*vCMiVNp9N1zXIMK$0_(Jq{u{rL_ zh+BZQ{kFu2IEm&`CCc?7BJU6u^w8ibh({T#_Gv|}f6l0Q0ki^CXeulnsCspxso(y+ z`@W=kMv8wfhWPz^dr_;0^d_QaTm#ydGzMWjD?<{leA(7Qlzxvwq26fLXf=Wy*xPKm z!a0{-qWgfEpcx*?i6~Rg<=YvtMyOUuRPN~?Jx?h$iFcHK{-&21Zw~VhHd84Efk1bY z#)o~!z6PkQ8RsG<&z6iQUwfJZ5x&)1k7_h)cD&O`Enu3R3%4dVz()96sxdO%0^seP za?4VmiiokR;1T-RWDW)20%(_2zfb+9E6tzDn}Oq8dm5-AD85LVvAkR8bj#-x@?(~J z&KkjfT(ie8#^^P;5{$ZJr`kToym3(ZBAGqY$b#2N&)qL3T{&Y$OxqGeMEz z8|NdsuVRWcbXh9nDgCsmB7bw$^;0pDadY6zjC>LU{Z!pP{$lJfHHjmQqhTWjVUn4P z-Xm_gt)4Y~iEYbyNx5c)UE_;mIHeh57-<(hGV43A%_*O7g?SdROGLZw-mjVhyvy8@_Fa-79<7Hb9mh+^uf;h6*ZUp&J%Wh z4Wiij;ceO2!Ca!#3&Ravf6IFVbPFa1HDFs{C%>W20Vk;971m@lMtuGPK-DXd9SoH6 zcpKi9iLY9x;ixX&!|ttT7${_jCsq zg)TMNUP?)odOlrrMLG^0sD!>KUaA);v(PN{^V_z43n!V*^43`FZ#mAl!Tj&&tLai(=NyGJ6;M$M< zaV;#V!ZN}`!~t;IzH>dK&b@vRwSaVM^$WMORt5*q92OweF>9Td4>|8^XOElNaTeg( zFm%L5y84tdN+h2_q;G%cx*~aRI%LYp70=r|{GCWjGao8%cfQ@mgZLrNl){WJ{B&#= z(=McN_tivxtr;2-uYVSbTmhS?+N9TK&IO+Az5qb|{^M|N;Su}|Vh1ea;BQBiKIVD_ zTb3xBPABtNrzZ>Q&mLn%>vm=ftGFj8a@*IV_$lD6-;o_}fEVx!`>o--**${4AWYV4 z8%p!|mBL^=39q7ibq-xNR7*Z=+z_aj=P+uVY}?=3Xc;JldCi4%|3^{%ibbrPyFRmj z@(rc$`a=Y4I$=Ih0DQdUYM!gFIDAkkm&~$Bfk0 zJY%_(GirOdd!M10PmZ>?9T$Q{FNXpEAdEa)Sn7zdYkTC>Skka6WXPg4nEdc7&f1^h zvu}d!Df6)ze1Yq!GCxTf%+W+`%X!7YD1)$Xx%bK~+lGoZg}{w%PA77l2yquiy{fD- zlH!(H!ajw94d`q>u$n@WWK6ULHL8HlG%aJ}Mv16VS)$cq|EPcJgJ`#3&&@h+y2|hl zAkqC9CPqnRJqVY+gm>?HV<<$Dt0?!=UPEW~8d8&$#co_mV_0;>K z4wH&&{dU4MH}}%j&kOuEw!Jb(l7N9Q&`j0b+4GOyU^SJR9jwuN-Y`ZW*kU-H>Xz99 z`Fso1XIYwxxo)`Vf&-P6x_D2F*U%{=tt>orN1!PnwV(cRiOIV;Zo(iGwYr${ZOKg1 z%we^3b^NTy`hY6oS`xP5V;Y7?b3sP>amRpDuylFUWPa~%n}(og#EXmZ@!9Sw$8$ix zQ_8e#yms_4<}0`_U%Hg%Ivjm3A6z~_I2|4WLq7aaeSYCD0AJIBnV>v1iM!t+giZhQ z7vNj8Isq}i{B1NRoo=ZeF~Kq)v(-(DvS4P8=7RY0Qe>O(u4n^K0a17d3JSy9T#<|1 zk`0-y%9tr-Aqhdn43)V zV_I>UWq%ySzZWw3;_MWPdpC%-ug?+`U>6Q`qfe%+OS?JN1E~iiEO7|0{RYj+91g8L zc4W9~2%4}DGrR6E`eui`0KMBrK5lCtZIzPx_)VP#i;Js%iQAz75HTom%w^ZwkLamNky=m$StEzi`X*N@ z){FrM(V++1LIH<~!{mg2a2#;Y_@TldY&)uT;6uNuqhIu?n7;tDJ{O&I2m3(5_eub> z8$XWuS&k@7$%P1ACpG?Aic(7MxC*(9#Jzue*$yUK8f}}6ffvFw@@k|LCX!cs(@G>b z?Xem$ZwmL-gQrMeeH=$Rk!rKL<uuTg?=HGA_Bc- zU?^g!E;|3H`7EWg z%(UD0=(&!XgWB`^GAQ(@RF8H;@%KI$hBLf6#nW-f4SnGs=K&pzM{e)|4y>8{SHWs2 zb{lxRjU%?fikTmT-IlL7E?1pg+&>*8P7aFZy318H%Up1eyiNK{}|H zpT;lnPS4wTw(pY1q5V0C7g!T=e0Q$b0Lt&`mkx-yHnv5hM0{y;tI!JuR`LfWfAk+#c%@McTJO2wj7kjVd53i z6Q`~gjP}XXa{++X-vI|64tdr22z`FFI8lmx*S-x_C}cjedHh>QuB)rqoySNSx=R)`c1-6uJJA+PL&RI(zuTC)D~WeW)|d zkTikhFs5|ossm?cIH%~9-%3_~>0PKL-%KH_)>ED#|~-emRBiStJV@J91-07*OMq@mUSL26~FEph><8rl@S`A%zd2C zaM2&{v>nor5$Y^2`pjiGEujwyVoDvh?-esI(F|9D#gjZzE1<@S^VQhYGd1$LZJ)&K z4c*(RM}@w^G5OW0j6?z^B5}M%2HV3~xK(1qRXowB_L!I0mO-wx)k5N?mv8CN)dYu; z;gqBzLA>#@hiJ~GqraOnWw ztNLt9soGKcH&R&C;#acxPSxT}6@8Xo!zEz-ow!XAcox(r_W6&(M-8_>iJ%8;&}p>o z<`J3;3?mTJ>_h>A6bge~9+5r#<&4ef>S}q!Lo^G{F*UE!Q8<=ZL*6Kh zAni^dSY+A7p|a2!dRk7&(?}aI0pWqtH4zya7xo1Cdc=Aa84XG4U{@(+aH>egx(u&j zpzN_{z%(BZsV=zM`%6Bh4;X#Z0YAAB2;IwQ-Zc@+?^IOdNep4xdL!Nr|)9 zZnkulgECMTE!}6CIfxCwjY~J`E+B2|T)O(?m;J{@QCKYQSKTTGCA>~?maw$L z0-@(z!aEh1+twF>>T#a>DzY`gJQrrpHKa+wHBeq0cV&U-;2ZBH3qQ=UuPElXxzoQ!c@=lIJhP%@ zE0a+&m?f{+k_We*Y1mZ+liL`<^l`&KhUwV*5?YeycG%J92%6s$X*zt7#!xX6=V~bw zhaCx&5rSI9u+;BmEc%%s3rivaS4IAQbq8)>o;2UpVZ+O?^_k#2D<$UxE$Jo6nnFekOE+F3LQkhk-;@Q)s@}Q`R}=Up zA@)of;pG|W@REDipTP2$gg>GmZX@Qd_NRq7?a7&b@4Y`K_PC{PPX6@|UI(6$=ZCI} zAmLmG^>DQ5wLdbx-W}Q%J>1c;1Nccbad1rWeg8u)D}oj%C!U~h*R$DM5JzdWxm397 zwio?&Z81pvybBGP2PJSFR>oa9A8pD7>8@7U;<`C#ZpX_W_yr`GP_G)d^?U|$X37k= z-Du-0UcPcs7ILO6kDhlkFNbN|JN;Iwu{ROUXR+OhCPvf9S3e|8OLM^MD9{FvuvZC1_adQ+`OtqLF!#VhGL*K6|8TJ`KKXT6-3~y1Yma4}j{{kU&$f zO0O+oh!thAJ{uBtRA3FtdXT=v{2V}(G`kurXt67Cx8ChGBUfn|pn-w7ZAdji9nyAUe(l)8` z_(K;&;n@`PTUGNJFHv{P1%A~<8$}d1daRS(N**`gc2Lnp$fVAN{!QD%GgAEF0K2Ek z#Z2^RW0qNzK{3T^JprfW+zAcRL+!F zs%3$Cyj(Tmn2aOXFD6=&=Ut2B5X2XJhvnfX zlQ~*w1&Tr4iRX*dZ2`_wUc5bjpjGkVB`tDk4lh~g4;ojB&9UuN2fZ~2vW&bH| zriu76IK0_$sG5@R!=Mpzx9s_F*p%h;tM(;#)gfCR0&FB>nQ-M*Io%G?~e%M(@ShF&mnb|%W5p5l#l!~q%TBsTN7B6g9f>6U+pmUk z0y*ZH7gsr3oeJ+xvfvutrC4QVv>IKyeQZ3HDU+zc@Bmhfnoz~Gv>GiH(dXoelMPun&FNH|wX{u|8v9=&fZr)i`cPGu zwUKdmVj-p)om5O{fG@n*RMp$A#t?q=%Y2aQ?YLRpzb|}%U-!n_WhEEm|53x?g4jR4 z%#BV>Oy#@?SlNNA#lf=)-24F#|D@ZxEux!$-cCKu$?gN*HdIT&JV9WAH`PRS5r@Ad z0IfJw6u1#OebVGh>MZGrsk4rCk=co-P<=Xh^l?Y##@pafKJt3;unY0laO}A@Xcf84f^o6vj+tyY|+8QfhqPp$KaxgA$yqSguK&hkq4)?Ne z>Cywb(zA6F|ulgWTg_Gtyg!q6Bg>37!|x!~%Jn)1$jPC4c#^Z;cj&4W4*hv~sTU04RaT{7vTGI(rj<2p7J9!Z)(RMOallAm1 ztC$i^X2{3hz2%eTiZ>iD)7d9h$2!Y5Tk8_~nLTX-Q0{%xQpNjp`^Ga0! zIEiB+QjAwqLl~l6|AQP_On@l0v3&0@K(fjm!pHtOv3+T7gmJthMiBl-L+{DNOi2SfvJk>d7jVo&-_Cuj+N3IsP zv_=jjLM$OWfW!ZiH-Gnt|L3xQAxW#O$K~6A4L$h9B6gWNWL%>aAZ9U4s+fmlO2Oyg z=fLfDmqU-a|K!*EQOAL^vKI~;!~y@C+#{FZ*4Cdy1Hul8UP>-k%=-i3+I<`sK4a-# z{rd#}u)C25byFkouvc$IImQQl`IU%N!-KYZ0@&x_hF8OVV{aKIUo51PlAi%L#=>15 z)~46+xTV=Au;wZOpwR#HAO{5V@(Q39>mw#TSxOv*JNqRk!p)XG%Jm4gPEKkTFRK_a zZMD5eZ!W>xnwN8`NJRfn|@&2sMPvcFZS8|9KHMs zJu@}YZuf3i(DHXtOP{rq=S7s#Gy+pJioz}c4lXN&XKedZ`z|DJU#ZOm*Zh_~jh6xU zC3K)b;Vx^o)kYqXTzj?$^y@-AvV6;TGq0qq$jwZkA*{FzSN0x6V!Vrp#U$>uv=r>4 z;*qbyJ-8rtCqf;6zC)3^2b_H>R zhkSG3*Bd{^t>Q=A6}O>9PzsD(R7R2kE=<3{&cAyHWFii!cTw~S+)QH3x!cth{dd1n zQ~h)%el?C(Cz&u4b=j1#IUc?`5Uaq@V+xj`z1@tKcZ`x0a;CzG74Ys?OXvLHQ}9!Zb7u--P~4X8w3Z;J3UHcxOxOP(8uJ3q7s^rw-|3+Y-bR zd`X2_i$ia9ELNU+1TRA;yay&0hH~vl$n4EvLGX~jKvl;1qtOkw<+j`8{D<=ijXI_C z`?Ak^PudM;Fc&@|@XKSmRPk?lRk88+g49#0PA7U(79Hoop{|RLv)~u=X5mdAKQX^~ z*(en2&C-ub?~a&AE1rM$7Q(kkKHoAhj4jf%;UgL#%6RWbc3K9dgCQtR_5JMEvy-Zs za{T2<9&VDSfM0m~M|$?&dLEBQSw1JRZ?j$XlpHrI;O zY(Tq-!?*A7Zmm>4X%A;KVIIFGJ1UJ<1~6z z4mrHVRA@b-XvlBs%NW^yu;>xdtgy(UX@nS!A4f;=HZs9g?7M$-GhY@(R(R<3kLA6% ztePCId^z-#u%wQ)0x2(?oZ)Qps3blb=JbM`oum`#-@BGI@f=`e4S&)i{T7?*l6=5_ z=Gc3Mw9Mu9g%3;poPMW!wt5Z%Nd16cDn^pIJX4)eOp9kqmV3o&bO3i6pIDR+gAg+= zn|s%T3L6L>vbW{5FO@rr)9>;c?ieE9$F|2r{Mnj5AP-_Ax1mfA{!T~r9G;}Wn-?sv z{8zp2NATW>aX{m{&UYE|+UVMYAa$~dm%IcB8+d8FZPH(r+KIZFaA!--bIqnLS1nPD zEWaXyudJO9_hi1iU87nxUk2A6!+p`~U!(05?my$rk}zBkvW6EuDBBNbE^z0}?8%hMr7Q#PWuN@>1OICcoGqv5Wua*;H2yMBxAyi z!LPOF{^xEleEaw-Ai{Ac?@8N2qQv$$P1-U)PN60K)`AP+uyBWAUJ6a4*-!R3XkAe2 zba8S8go!`BUu*mQ3gr&N-}E z%XJ2`r*{%KrcdbS5LX*PNLulk;X%r}&2Fmr1HLaaa}jgX_#lHo$| zvK>^K?kDa&G_oyx`-U;weUE#;KvNwKp6k4v zY>_RDgcw&N)L5k?Zu>@b<6od`AunEN|4m~i}?56auwt50{+`WfJ#OGY9DKEUJ zye3QR_;{oYr!CTtLQSiS7#~+!(Px=$2}c%(iDjOPgCtr8x!GWG*23D<{+?$jw$)BT z3972#HiqXN8@mIjI{xPko_83)0e|5e0Pb%9;TuP70OEz(^j^d7!|xsMglb~fxiNx( z!v$4Og&ie09435DJM~(cIqKZvi&uvka8LOamg$1J zlh1q+x@r;X+Jy~Y9qgy((>P;G$$-u|W3G1ix5?{2)&4knC>sZ%N3COJ+OA|-~4z7{$VcWa)TXPj;S){ zbkT$#}33ow#Wl?y=yaRy%< zv8=WpTfYJD)%XB79Qw23174on0KOfYr+-$`iT(cYPLT0SxGVw&0jN?W60zZt`GB+P NKac#!;^AK-{|5;|5O4qh literal 0 HcmV?d00001 diff --git a/CMakeLists.txt b/CMakeLists.txt index 2882d00..304a200 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -33,6 +33,17 @@ FetchContent_Declare(httplib ) FetchContent_MakeAvailable(httplib) +# --- stb (single-header JPEG writer for the camera feed) -------------------------------------- +# Header-only and dependency-free, which is the point: the render path must not +# drag an image library into a physics server that runs on Fargate. +FetchContent_Declare(stb + GIT_REPOSITORY https://github.com/nothings/stb.git + GIT_TAG f0569113c93ad095470c54bf34a17b36646bbbb5 +) +FetchContent_MakeAvailable(stb) +add_library(stb_image_write INTERFACE) +target_include_directories(stb_image_write SYSTEM INTERFACE ${stb_SOURCE_DIR}) + # --- coverage instrumentation (our targets only; third-party stays clean) -------------------- # Everything declared after this point picks up --coverage; Jolt/httplib are declared above. option(SKYSIM_COVERAGE "Instrument skysim targets for gcov/gcovr" OFF) @@ -73,6 +84,15 @@ target_link_libraries(skysim_core PRIVATE Jolt) target_compile_options(skysim_core PRIVATE -Wall -Wextra -Wshadow) # --- terrain: OBJ -> MeshShape cooker + tile streamer ---------------------------------------- +add_library(skysim_render STATIC + src/render/renderer.cpp + src/render/raster.cpp + src/render/render_service.cpp + src/render/jpeg.cpp +) +target_include_directories(skysim_render PUBLIC src) +target_link_libraries(skysim_render PUBLIC skysim_core PRIVATE stb_image_write) + add_library(skysim_terrain STATIC src/terrain/cook.cpp src/terrain/tile_streamer.cpp @@ -84,7 +104,7 @@ target_compile_options(skysim_terrain PRIVATE -Wall -Wextra -Wshadow) # --- skysim server -------------------------------------------------------------------------- add_executable(skysim src/main.cpp) target_include_directories(skysim PRIVATE src) -target_link_libraries(skysim PRIVATE skysim_protocol skysim_core skysim_vehicle skysim_api skysim_terrain) +target_link_libraries(skysim PRIVATE skysim_protocol skysim_core skysim_vehicle skysim_api skysim_terrain skysim_render) target_compile_options(skysim PRIVATE -Wall -Wextra -Wshadow) # --- world-step benchmark (performance) ------------------------------------------------------ @@ -101,6 +121,10 @@ target_link_libraries(reply_bench PRIVATE skysim_protocol Threads::Threads) target_compile_options(reply_bench PRIVATE -Wall -Wextra -Wshadow) # --- tile cooker ------------------------------------------------------------------------------ +add_executable(render_probe tools/render_probe/main.cpp) +target_include_directories(render_probe PRIVATE src) +target_link_libraries(render_probe PRIVATE skysim_render skysim_core) + add_executable(tile_cooker tools/cooker/main.cpp) target_include_directories(tile_cooker PRIVATE src) target_link_libraries(tile_cooker PRIVATE skysim_terrain) @@ -139,6 +163,11 @@ target_link_libraries(test_collision PRIVATE skysim_core skysim_terrain) target_compile_options(test_collision PRIVATE -Wall -Wextra -Wshadow) add_test(NAME collision COMMAND test_collision) +add_executable(test_render tests/test_render.cpp) +target_link_libraries(test_render PRIVATE skysim_render skysim_core skysim_terrain) +target_compile_options(test_render PRIVATE -Wall -Wextra -Wshadow) +add_test(NAME render COMMAND test_render) + add_executable(test_endpoint tests/test_endpoint.cpp) target_link_libraries(test_endpoint PRIVATE skysim_protocol) target_compile_options(test_endpoint PRIVATE -Wall -Wextra -Wshadow) diff --git a/README.md b/README.md index df9060c..8df2ed4 100644 --- a/README.md +++ b/README.md @@ -84,6 +84,30 @@ Read in depth: [`docs/PROTOCOL.md`](docs/PROTOCOL.md) → [`docs/DESIGN.md`](doc --- +## Getting started + +One command, and the only prerequisites are the build tools. It builds skysim, cooks a +1.2 km demo city, and renders it from four camera poses — the same rasteriser that feeds +a vehicle's camera stream in flight, so what you get is what a drone would see: + +```bash +tools/getting_started.sh +``` + +![Four views of the demo city rendered by skysim](.github/assets/skysim-getting-started.jpg) + +Images land in `build/getting_started/`. Nothing above needs ArduPilot, a network or a +running simulator — it is there so you can see the thing work before committing to the +half hour the autopilot build takes. + +Once you do have ArduPilot (see below), the same script will fly a vehicle through that +city and serve its camera live: + +```bash +ARDUPILOT_ROOT=~/ardupilot tools/getting_started.sh --fly +# then open http://127.0.0.1:8642/instances/0/camera.mjpg +``` + ## Quick start **Prerequisites** — Ubuntu 22.04/24.04, CMake ≥ 3.24, Ninja, GCC 12+ or Clang 16+, and an @@ -230,9 +254,12 @@ src/core/ frames.h (NED/FRD <-> Jolt), world.cpp (the ONLY Jolt-aware TU), src/vehicle/ motor lag + X-quad mixer + instance allocator / process manager src/terrain/ OBJ -> MeshShape cooker + proximity tile streamer src/api/ REST control plane (cpp-httplib) +src/render/ threaded software rasteriser + JPEG/MJPEG camera feed per vehicle tools/cooker/ pretile.py (demo city) + osm_buildings.py (real city) + tile_cooker CLI tools/bench/ gated world, protocol, and UDP reply-path performance benchmarks tools/harness/ conformance / determinism / straggler / churn / collision / corridor +tools/render_probe/ render one frame from a given camera pose, to a JPEG +tools/getting_started.sh build + cook + render the demo city (see "Getting started") tests/ unit tests + app_smoke.py driving the real binary ``` diff --git a/src/api/control_server.cpp b/src/api/control_server.cpp index 45ad4a4..ea6d5e6 100644 --- a/src/api/control_server.cpp +++ b/src/api/control_server.cpp @@ -15,6 +15,13 @@ namespace skysim::api { +namespace { +// Two long-lived camera streams per vehicle, plus headroom for the control +// plane, which must stay answerable while they run. +constexpr size_t kWorkerThreads = 64; +} // namespace + + namespace { // {"launch_process":true} — absent key means false. @@ -157,6 +164,16 @@ ControlServer::ControlServer(const std::string &bind_addr, int port, CommandQueu : impl_(std::make_unique()) { auto &s = impl_->server; + // Camera streams hold a worker for their whole life. + // + // Each MJPEG response sits in its chunked provider until the client goes + // away, so it occupies one thread the entire time — and every simulated + // vehicle opens two (the core container's encoder and the recorder). At + // httplib's default pool of max(8, ncpu-1), four vehicles on a 2-vCPU task + // would consume every worker and /vehicles, /metrics and spawn would simply + // stop answering. Sized for streams rather than for cores. + s.new_task_queue = [] { return new httplib::ThreadPool(kWorkerThreads); }; + s.Post("/vehicles", [&queue](const httplib::Request &req, httplib::Response &res) { SpawnCommand cmd; cmd.request.launch_process = parse_launch_process(req.body); @@ -265,6 +282,121 @@ ControlServer::ControlServer(const std::string &bind_addr, int port, CommandQueu res.set_content(out, "application/json"); }); + // The same two feeds addressed by ArduPilot instance. + // + // skysim's own vehicle ids are allocated internally and change when it + // restarts; the instance is what the gateway assigns and everything else + // already agrees on. A consumer that can only bake a URL into a config — + // an ffmpeg command line, say — needs one that stays true. + auto resolve_instance = [snapshots](const std::string &instance) -> uint32_t { + if (!snapshots.vehicles) { + return 0; + } + for (const auto &v : snapshots.vehicles()) { + if (std::to_string(v.instance) == instance) { + return v.id; + } + } + return 0; + }; + + s.Get(R"(/instances/(\d+)/camera.jpg)", [snapshots, resolve_instance](const httplib::Request &req, + httplib::Response &res) { + const uint32_t id = resolve_instance(req.matches[1].str()); + if (id == 0 || !snapshots.camera_frame) { + res.status = 404; + res.set_content("{\"error\":\"no such instance\"}", "application/json"); + return; + } + std::vector jpeg = snapshots.camera_frame(id); + if (jpeg.empty()) { + res.status = 404; + res.set_content("{\"error\":\"no frame\"}", "application/json"); + return; + } + res.set_content(reinterpret_cast(jpeg.data()), jpeg.size(), "image/jpeg"); + }); + + s.Get(R"(/instances/(\d+)/camera.mjpg)", [snapshots, resolve_instance](const httplib::Request &req, + httplib::Response &res) { + const std::string instance = req.matches[1].str(); + if (!snapshots.camera_frame) { + res.status = 404; + res.set_content("{\"error\":\"camera disabled\"}", "application/json"); + return; + } + res.set_chunked_content_provider( + "multipart/x-mixed-replace; boundary=skysimframe", + [snapshots, resolve_instance, instance](size_t, httplib::DataSink &sink) { + // Re-resolved per frame, so a skysim restart mid-recording picks + // the vehicle back up instead of streaming nothing forever. + const uint32_t id = resolve_instance(instance); + std::vector jpeg = id != 0 ? snapshots.camera_frame(id) : std::vector{}; + if (!jpeg.empty()) { + char head[128]; + const int n = std::snprintf(head, sizeof(head), + "--skysimframe\r\nContent-Type: image/jpeg\r\n" + "Content-Length: %zu\r\n\r\n", + jpeg.size()); + sink.write(head, static_cast(n)); + sink.write(reinterpret_cast(jpeg.data()), jpeg.size()); + sink.write("\r\n", 2); + } + std::this_thread::sleep_for(std::chrono::milliseconds(40)); + return true; + }); + }); + + // One frame, for a poll-based viewer or a quick look with curl. + s.Get(R"(/vehicles/(\d+)/camera.jpg)", [snapshots](const httplib::Request &req, httplib::Response &res) { + if (!snapshots.camera_frame) { + res.status = 404; + res.set_content("{\"error\":\"camera disabled\"}", "application/json"); + return; + } + std::vector jpeg = snapshots.camera_frame( + static_cast(std::stoul(req.matches[1].str()))); + if (jpeg.empty()) { + res.status = 404; + res.set_content("{\"error\":\"no frame\"}", "application/json"); + return; + } + res.set_content(reinterpret_cast(jpeg.data()), jpeg.size(), "image/jpeg"); + }); + + // MJPEG, which is what the video pipeline consumes. + // + // A chunked multipart stream rather than a socket of raw frames because it + // crosses a container boundary and GStreamer, ffmpeg and a plain browser tab + // can all open it without agreeing on anything first. + s.Get(R"(/vehicles/(\d+)/camera.mjpg)", [snapshots](const httplib::Request &req, httplib::Response &res) { + if (!snapshots.camera_frame) { + res.status = 404; + res.set_content("{\"error\":\"camera disabled\"}", "application/json"); + return; + } + const auto id = static_cast(std::stoul(req.matches[1].str())); + res.set_chunked_content_provider( + "multipart/x-mixed-replace; boundary=skysimframe", + [snapshots, id](size_t /*offset*/, httplib::DataSink &sink) { + std::vector jpeg = snapshots.camera_frame(id); + if (!jpeg.empty()) { + char head[128]; + const int n = std::snprintf(head, sizeof(head), + "--skysimframe\r\nContent-Type: image/jpeg\r\n" + "Content-Length: %zu\r\n\r\n", + jpeg.size()); + sink.write(head, static_cast(n)); + sink.write(reinterpret_cast(jpeg.data()), jpeg.size()); + sink.write("\r\n", 2); + } + // Poll a little faster than frames are produced, so the stream + // tracks the render rate instead of setting its own. + std::this_thread::sleep_for(std::chrono::milliseconds(40)); + return true; + }); + }); + s.Get("/metrics", [snapshots](const httplib::Request &, httplib::Response &res) { const MetricsInfo m = snapshots.metrics(); char buf[512]; diff --git a/src/api/control_server.h b/src/api/control_server.h index 8b947fa..d4e9299 100644 --- a/src/api/control_server.h +++ b/src/api/control_server.h @@ -103,6 +103,10 @@ class ControlServer { struct Snapshots { std::function()> vehicles; std::function metrics; + // Latest camera frame for a vehicle as encoded JPEG; empty when the + // camera is off or that vehicle has not been rendered yet. Published by + // the tick thread like everything else here. + std::function(uint32_t)> camera_frame; }; // Binds bind_addr:port and serves on its own thread. Throws on bind failure (startup diff --git a/src/core/world.cpp b/src/core/world.cpp index 80fa39d..876c683 100644 --- a/src/core/world.cpp +++ b/src/core/world.cpp @@ -162,6 +162,7 @@ struct World::Impl { uint32_t next_id = 1; std::unordered_map vehicles; std::vector static_bodies; + JPH::BodyID ground_body; // also in static_bodies; kept apart so drawing can name it std::unordered_map static_tiles; // streamed (M5) // Body indices of resident tiles, so contact attribution can distinguish a // building strike from an ordinary ground touch without a linear scan. @@ -233,6 +234,7 @@ void World::add_ground_plane() { s.mRestitution = 0.0f; const JPH::BodyID id = impl_->bodies().CreateAndAddBody(s, JPH::EActivation::DontActivate); impl_->static_bodies.push_back(id); + impl_->ground_body = id; } namespace { @@ -303,6 +305,47 @@ void World::remove_static_tile(uint32_t id) { impl_->static_tiles.erase(it); } +std::vector World::collect_static_triangles() const { + std::vector out; + const JPH::BodyInterface &bodies = impl_->bodies(); + + // Both routes into the world: bulk-loaded tiles (and the ground slab) live in + // static_bodies, streamed ones in static_tiles. Missing either draws a world + // with holes in it exactly where the geometry came from the other path. + std::vector all; + all.reserve(impl_->static_bodies.size() + impl_->static_tiles.size()); + all.insert(all.end(), impl_->static_bodies.begin(), impl_->static_bodies.end()); + for (const auto &[id, body] : impl_->static_tiles) { + all.push_back(body); + } + + for (const JPH::BodyID &body : all) { + const bool is_ground = body == impl_->ground_body; + const JPH::TransformedShape shape = bodies.GetTransformedShape(body); + + // Jolt walks a shape's triangles in batches through this cursor; it is + // the same path the debug renderer uses. + JPH::Shape::GetTrianglesContext ctx; + constexpr int kBatch = 256; + JPH::Float3 verts[kBatch * 3]; + shape.GetTrianglesStart(ctx, JPH::AABox::sBiggest(), JPH::RVec3::sZero()); + for (;;) { + const int count = shape.GetTrianglesNext(ctx, kBatch, verts); + if (count == 0) { + break; + } + for (int i = 0; i < count; ++i) { + const JPH::Float3 &a = verts[i * 3 + 0]; + const JPH::Float3 &b = verts[i * 3 + 1]; + const JPH::Float3 &c = verts[i * 3 + 2]; + out.push_back({from_jolt(JPH::Vec3(a.x, a.y, a.z)), from_jolt(JPH::Vec3(b.x, b.y, b.z)), + from_jolt(JPH::Vec3(c.x, c.y, c.z)), is_ground}); + } + } + } + return out; +} + void World::optimize_broadphase() { impl_->physics->OptimizeBroadPhase(); } double World::raycast(const Vec3 &origin_ned, const Vec3 &dir_ned, double max_dist_m, uint32_t ignore_vehicle_id, @@ -331,6 +374,39 @@ double World::raycast(const Vec3 &origin_ned, const Vec3 &dir_ned, double max_di return -1.0; } +World::RayHit World::raycast_surface(const Vec3 &origin_ned, const Vec3 &dir_ned, double max_dist_m, + uint32_t ignore_vehicle_id) const { + RayHit out; + const JPH::RVec3 origin(to_jolt(origin_ned)); + const JPH::Vec3 dir = to_jolt(dir_ned) * static_cast(max_dist_m); + JPH::RRayCast ray{origin, dir}; + JPH::RayCastResult hit; + JPH::BodyID ignore; + if (ignore_vehicle_id != 0) { + auto it = impl_->vehicles.find(ignore_vehicle_id); + if (it != impl_->vehicles.end()) { + ignore = it->second.body; + } + } + const JPH::IgnoreSingleBodyFilter body_filter(ignore); + const JPH::BroadPhaseLayerFilter any_bp; + const JPH::ObjectLayerFilter any_object; + if (!impl_->physics->GetNarrowPhaseQuery().CastRay(ray, hit, any_bp, any_object, body_filter)) { + return out; + } + + out.hit = true; + out.distance = hit.mFraction * max_dist_m; + + // Ask the body that was hit which way its surface faces at the hit point. + const JPH::RVec3 point = ray.GetPointOnRay(hit.mFraction); + const JPH::Vec3 normal = + impl_->bodies().GetTransformedShape(hit.mBodyID).GetWorldSpaceSurfaceNormal(hit.mSubShapeID2, point); + out.normal_ned = from_jolt(normal); + out.is_ground = hit.mBodyID == impl_->ground_body; + return out; +} + std::vector World::sweep_path(const std::vector &waypoints_ned, double clearance_m) const { std::vector hits; if (waypoints_ned.size() < 2) { diff --git a/src/core/world.h b/src/core/world.h index eb90d5a..1e810a8 100644 --- a/src/core/world.h +++ b/src/core/world.h @@ -105,6 +105,38 @@ class World { double raycast(const Vec3 &origin_ned, const Vec3 &dir_ned, double max_dist_m, uint32_t ignore_vehicle_id = 0, bool static_only = false) const; + // What a ray hit, with enough to shade it. The plain raycast above answers + // "how far", which is all a rangefinder needs; drawing the world needs to + // know which way the surface faces. + struct RayHit { + bool hit{false}; + double distance{-1.0}; + Vec3 normal_ned{0.0, 0.0, -1.0}; // unit, points back towards the ray + bool is_ground{false}; // the ground slab, as opposed to a building + }; + + RayHit raycast_surface(const Vec3 &origin_ned, const Vec3 &dir_ned, double max_dist_m, + uint32_t ignore_vehicle_id = 0) const; + + // One triangle of static geometry, in NED, wound so the normal points out. + struct Triangle { + Vec3 a{}; + Vec3 b{}; + Vec3 c{}; + // Which body it came from, not which way it faces. A renderer cannot tell + // a flat roof from a field by its normal — both point straight up — and + // guessing paints lawns on every rooftop in the city. + bool is_ground{false}; + }; + + // Every triangle of every resident building tile. + // + // For drawing, not for physics: a rasteriser needs the geometry itself, + // where a raycast only ever needed the answer. Pulled through here rather + // than read from the tile files so what gets drawn is exactly what is + // loaded — including streaming, which adds and drops tiles as vehicles move. + std::vector collect_static_triangles() const; + // Vehicles (tick boundaries only). Returns body id used by the calls below. uint32_t add_vehicle(const VehicleBodyParams &p); void remove_vehicle(uint32_t id); diff --git a/src/main.cpp b/src/main.cpp index 0026ebe..b486a2e 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -22,6 +22,7 @@ #include "core/thread_pool.h" #include "core/world.h" #include "protocol/packets.h" +#include "render/render_service.h" #include "protocol/udp_endpoint.h" #include "terrain/tile_streamer.h" #include "vehicle/manager.h" @@ -59,6 +60,21 @@ struct Options { int api_port = 0; // 0 = control plane disabled std::string api_bind = "127.0.0.1"; // 0.0.0.0 when the gateway calls from a bridge net int hold_ticks = 3; // interactive: reuse last PWM for up to k missed deadlines + // Fraction of a tick a frame may be late before it counts as a straggler. + double frame_grace = 0.7; + + // Camera feed. Off unless asked for: rendering costs rays per pixel, and a + // headless fleet run has nobody watching. + double camera_fps = 0.0; // 0 = no camera + int camera_width = 256; + int camera_height = 144; + double camera_fov_deg = 78.0; + double camera_pitch_deg = -15.0; + double camera_range_m = 350.0; + int camera_quality = 70; + // Worker threads for the render pass. They are outside the physics tick, so + // this trades cores for frame rate and nothing else. + int camera_threads = 4; double grace_s = 30.0; // interactive: frozen longer than this => auto-despawn double strict_timeout_s = 10.0; // strict: barrier stalled longer => abort with report std::string spawn_binary; // arducopter for POST /vehicles {"launch_process":true} @@ -128,6 +144,26 @@ Options parse_args(int argc, char **argv) { o.api_port = std::atoi(need_value("--api-port")); } else if (std::strcmp(argv[i], "--api-bind") == 0) { o.api_bind = need_value("--api-bind"); + } else if (std::strcmp(argv[i], "--camera-fps") == 0) { + o.camera_fps = std::atof(need_value("--camera-fps")); + } else if (std::strcmp(argv[i], "--camera-size") == 0) { + // WxH, e.g. 256x144 + const char *v = need_value("--camera-size"); + const char *x = std::strchr(v, 'x'); + o.camera_width = std::atoi(v); + o.camera_height = (x != nullptr) ? std::atoi(x + 1) : 0; + } else if (std::strcmp(argv[i], "--camera-fov") == 0) { + o.camera_fov_deg = std::atof(need_value("--camera-fov")); + } else if (std::strcmp(argv[i], "--camera-pitch") == 0) { + o.camera_pitch_deg = std::atof(need_value("--camera-pitch")); + } else if (std::strcmp(argv[i], "--camera-range") == 0) { + o.camera_range_m = std::atof(need_value("--camera-range")); + } else if (std::strcmp(argv[i], "--camera-quality") == 0) { + o.camera_quality = std::atoi(need_value("--camera-quality")); + } else if (std::strcmp(argv[i], "--camera-threads") == 0) { + o.camera_threads = std::atoi(need_value("--camera-threads")); + } else if (std::strcmp(argv[i], "--frame-grace") == 0) { + o.frame_grace = std::atof(need_value("--frame-grace")); } else if (std::strcmp(argv[i], "--hold-ticks") == 0) { o.hold_ticks = std::atoi(need_value("--hold-ticks")); } else if (std::strcmp(argv[i], "--grace") == 0) { @@ -538,6 +574,10 @@ struct App { std::vector vehicle_snapshot; skysim::api::MetricsInfo metrics_snapshot; + // Camera feed. Rendered on this thread because raycasting a stepping world + // is not safe; published for the HTTP thread exactly like the snapshots above. + std::unique_ptr render_service; + std::unique_ptr make_slot(int instance) { auto slot = std::make_unique(instance); slot->vehicle_id = next_vehicle_id++; @@ -655,6 +695,24 @@ struct App { manager->reap(); } + // Hand the render thread the poses it should draw from. + // + // Deliberately all this tick does for the camera: a few doubles under a + // mutex. Rendering itself happens on RenderService's own thread against its + // own copy of the static world, because a single row of raycasting costs + // about as much as the entire tick budget. + void publish_frames() { + if (!render_service) { + return; + } + std::vector poses; + poses.reserve(fleet.size()); + for (const auto &v : fleet) { + poses.push_back({v->vehicle_id, v->state.pos_ned, v->state.quat_ned_frd}); + } + render_service->publish_poses(std::move(poses)); + } + void publish_snapshot() { std::vector infos; infos.reserve(fleet.size()); @@ -743,10 +801,30 @@ int run_strict(App &app, FILE *truth_log, FILE *record_log) { return rc; } app.publish_snapshot(); + app.publish_frames(); } return 0; } +// Poll every connected vehicle until all have a frame staged or the deadline passes. +// +// Frames are staged into the slot by poll_endpoint, so a vehicle that answers +// early is not polled again and the loop exits as soon as the slowest one lands. +void wait_for_frames(Fleet &fleet, std::chrono::steady_clock::time_point deadline) { + for (;;) { + bool all_ready = true; + for (auto &v : fleet) { + if (v->connected && !poll_endpoint(*v)) { + all_ready = false; + } + } + if (all_ready || std::chrono::steady_clock::now() >= deadline) { + return; + } + std::this_thread::sleep_for(std::chrono::microseconds(50)); + } +} + // Interactive: tick on schedule; a vehicle missing its deadline gets its last PWM held for // up to k ticks, then freezes (kinematic hold, flagged), then despawns after the grace. int run_interactive(App &app, FILE *truth_log, FILE *record_log) { @@ -754,6 +832,11 @@ int run_interactive(App &app, FILE *truth_log, FILE *record_log) { const auto dt = std::chrono::duration_cast(std::chrono::duration(app.opt.dt_s)); auto next_tick = clock::now() + dt; const uint64_t grace_ticks = static_cast(app.opt.grace_s / app.opt.dt_s); + // How much of a tick a late frame may use before it counts as late. Kept + // under the period so a persistently slow vehicle still shows up as one + // rather than silently stretching every tick. + const auto frame_grace = + std::chrono::duration_cast(std::chrono::duration(app.opt.dt_s * app.opt.frame_grace)); while (!g_stop.load(std::memory_order_relaxed)) { std::this_thread::sleep_until(next_tick); @@ -765,6 +848,20 @@ int run_interactive(App &app, FILE *truth_log, FILE *record_log) { app.drain_commands(); app.stream_tiles(); + // Give a late frame a bounded moment to land before calling it late. + // + // take_latest() is non-blocking, so polling it once at the wall-clock + // instant asks "has the frame arrived YET", when lockstep only cares + // whether it arrives WITHIN the tick. A round trip a hair longer than + // the period then reads as a straggler even though the frame lands + // microseconds later — measured at 19% of ticks on an idle 16-core box, + // with the vehicle holding stale PWM for every one of them. + // + // Waiting a fraction of the period costs nothing when frames are early + // (the loop breaks out immediately) and converts most of those false + // stragglers into ordinary on-time ticks. + wait_for_frames(app.fleet, clock::now() + frame_grace); + for (auto &v : app.fleet) { const bool fresh = poll_endpoint(*v); if (fresh) { @@ -806,6 +903,7 @@ int run_interactive(App &app, FILE *truth_log, FILE *record_log) { } } app.publish_snapshot(); + app.publish_frames(); } return 0; } @@ -888,6 +986,28 @@ int main(int argc, char **argv) { app.io_pool = std::make_unique(opt.io_threads); } + if (opt.camera_fps > 0.0) { + if (opt.camera_width <= 0 || opt.camera_height <= 0) { + std::fprintf(stderr, "skysim: --camera-size must be WxH with both > 0\n"); + return 1; + } + skysim::render::RenderService::Config rcfg; + rcfg.camera.width = opt.camera_width; + rcfg.camera.height = opt.camera_height; + rcfg.camera.fov_deg = opt.camera_fov_deg; + rcfg.camera.pitch_deg = opt.camera_pitch_deg; + rcfg.camera.max_range_m = opt.camera_range_m; + rcfg.tiles_dir = opt.tiles; + rcfg.fps = opt.camera_fps; + rcfg.quality = opt.camera_quality; + rcfg.threads = opt.camera_threads; + app.render_service = std::make_unique(rcfg); + std::printf("skysim: camera world has %zu tile(s), %zu triangle(s)\n", app.render_service->tiles_loaded(), + app.render_service->triangle_count()); + std::printf("skysim: camera %dx%d @ %.1f fps on %d render thread(s)\n", opt.camera_width, + opt.camera_height, opt.camera_fps, opt.camera_threads); + } + if (replaying && (!app.world || opt.time_mode != "strict")) { std::fprintf(stderr, "skysim: --replay-servo requires strict mode + jolt physics\n"); return 1; @@ -952,6 +1072,9 @@ int main(int argc, char **argv) { std::lock_guard lock(app.snapshot_mutex); return app.metrics_snapshot; }; + snaps.camera_frame = [&app](uint32_t id) { + return app.render_service ? app.render_service->frame(id) : std::vector{}; + }; api = std::make_unique(opt.api_bind, opt.api_port, app.queue, std::move(snaps)); } diff --git a/src/render/camera.h b/src/render/camera.h new file mode 100644 index 0000000..bbde395 --- /dev/null +++ b/src/render/camera.h @@ -0,0 +1,80 @@ +#pragma once + +#include "core/frames.h" + +#include +#include + +namespace skysim::render { + +using core::Quat; +using core::Vec3; + +// Deliberately small. This is a situational view for a pilot flying by stick — +// "is there a building in front of me", not an inspection image — and every +// pixel costs a ray through the physics world. 256x144 at 10 Hz is about 370k +// rays a second per vehicle, which a handful of cores can hold while the +// simulation keeps its tick. +struct CameraConfig { + int width{256}; + int height{144}; + double fov_deg{78.0}; // horizontal, close to a typical drone camera + double pitch_deg{-15.0}; // nose-down tilt of the mount, degrees + double max_range_m{350.0}; // draw distance; rays cost, and haze hides the cut +}; + +// Rays for a pinhole camera rigidly mounted to the airframe. +// +// The vehicle's attitude comes in as the NED->FRD quaternion the physics uses, +// so the mount tilt is applied in body axes and the result rotated into world +// axes — the same order the real gimbal sits in. +class Camera { + public: + Camera(const CameraConfig &cfg, const Vec3 &position_ned, const Quat &quat_ned_frd) + : cfg_(cfg), position_(position_ned), quat_(core::quat_normalize(quat_ned_frd)) { + const double aspect = static_cast(cfg.width) / static_cast(cfg.height); + tan_half_fov_x_ = std::tan((cfg.fov_deg * M_PI / 180.0) * 0.5); + tan_half_fov_y_ = tan_half_fov_x_ / aspect; + const double p = cfg.pitch_deg * M_PI / 180.0; + cos_pitch_ = std::cos(p); + sin_pitch_ = std::sin(p); + } + + const Vec3 &position() const { return position_; } + + // Unit ray direction in NED for a pixel. x right, y down, both 0-based. + Vec3 ray(int x, int y) const { + // Pixel centre in [-1, 1], y flipped so row 0 is the top of the image. + const double sx = ((x + 0.5) / cfg_.width) * 2.0 - 1.0; + const double sy = 1.0 - ((y + 0.5) / cfg_.height) * 2.0; + + // Camera-local FRD: forward is +x, right +y, down +z. + const double fwd = 1.0; + const double right = sx * tan_half_fov_x_; + const double down = -sy * tan_half_fov_y_; + + // Mount tilt about the body's right axis (pitch). Negative pitch looks down. + const Vec3 tilted{fwd * cos_pitch_ + down * sin_pitch_, right, -fwd * sin_pitch_ + down * cos_pitch_}; + + return normalize(core::quat_rotate(quat_, tilted)); + } + + private: + static Vec3 normalize(const Vec3 &v) { + const double n = std::sqrt(v[0] * v[0] + v[1] * v[1] + v[2] * v[2]); + if (n <= 0.0) { + return {1.0, 0.0, 0.0}; + } + return {v[0] / n, v[1] / n, v[2] / n}; + } + + CameraConfig cfg_; + Vec3 position_; + Quat quat_; + double tan_half_fov_x_{1.0}; + double tan_half_fov_y_{1.0}; + double cos_pitch_{1.0}; + double sin_pitch_{0.0}; +}; + +} // namespace skysim::render diff --git a/src/render/frame_store.h b/src/render/frame_store.h new file mode 100644 index 0000000..3bda577 --- /dev/null +++ b/src/render/frame_store.h @@ -0,0 +1,54 @@ +#pragma once + +#include +#include +#include +#include + +namespace skysim::render { + +// Latest camera frame per vehicle, handed from the tick thread to the HTTP thread. +// +// Rendering happens on the tick thread because casting rays into the physics +// world while it is stepping is not safe, and because the server's whole +// threading contract is that the HTTP side only ever reads what a tick +// published. This is that publication: one encoded JPEG per vehicle, replaced +// whole, copied out under the lock. +// +// Frames are dropped rather than queued. A viewer that falls behind wants the +// newest picture, not a backlog of stale ones. +class FrameStore { + public: + struct Frame { + std::vector jpeg; + double sim_time_s{0.0}; + }; + + void publish(uint32_t vehicle_id, std::vector jpeg, double sim_time_s) { + std::lock_guard lock(mutex_); + Frame &slot = frames_[vehicle_id]; + slot.jpeg = std::move(jpeg); + slot.sim_time_s = sim_time_s; + } + + // Empty if that vehicle has never had a frame rendered. + Frame get(uint32_t vehicle_id) const { + std::lock_guard lock(mutex_); + auto it = frames_.find(vehicle_id); + if (it == frames_.end()) { + return {}; + } + return it->second; + } + + void erase(uint32_t vehicle_id) { + std::lock_guard lock(mutex_); + frames_.erase(vehicle_id); + } + + private: + mutable std::mutex mutex_; + std::unordered_map frames_; +}; + +} // namespace skysim::render diff --git a/src/render/image.h b/src/render/image.h new file mode 100644 index 0000000..0edc22e --- /dev/null +++ b/src/render/image.h @@ -0,0 +1,21 @@ +#pragma once + +#include +#include + +namespace skysim::render { + +// An RGB8 image, tightly packed, row-major from the top-left. +// +// Its own header because everything downstream of a renderer wants it — the JPEG +// encoder, the frame store, the service — and none of them should have to +// include a particular renderer to get it. +struct Image { + int width{0}; + int height{0}; + std::vector rgb; + + bool empty() const { return rgb.empty(); } +}; + +} // namespace skysim::render diff --git a/src/render/jpeg.cpp b/src/render/jpeg.cpp new file mode 100644 index 0000000..e532ab4 --- /dev/null +++ b/src/render/jpeg.cpp @@ -0,0 +1,33 @@ +#include "render/jpeg.h" + +#define STB_IMAGE_WRITE_IMPLEMENTATION +// Writing to a file is never wanted here — frames go straight out over HTTP — +// and pulling in stdio widens what a physics server links against for nothing. +#define STBI_WRITE_NO_STDIO +#include + +namespace skysim::render { +namespace { + +void append_chunk(void *context, void *data, int size) { + auto *out = static_cast *>(context); + const auto *bytes = static_cast(data); + out->insert(out->end(), bytes, bytes + size); +} + +} // namespace + +std::vector encode_jpeg(const Image &image, int quality) { + std::vector out; + if (image.empty() || image.width <= 0 || image.height <= 0) { + return out; + } + + out.reserve(static_cast(image.width) * image.height / 4); + if (stbi_write_jpg_to_func(&append_chunk, &out, image.width, image.height, 3, image.rgb.data(), quality) == 0) { + out.clear(); + } + return out; +} + +} // namespace skysim::render diff --git a/src/render/jpeg.h b/src/render/jpeg.h new file mode 100644 index 0000000..3f17d05 --- /dev/null +++ b/src/render/jpeg.h @@ -0,0 +1,20 @@ +#pragma once + +#include "render/image.h" + +#include +#include + +namespace skysim::render { + +// Encode an RGB8 image as baseline JPEG. +// +// JPEG rather than raw frames because the consumer is a GStreamer pipeline in +// another container: a 256x144 frame is ~110 kB raw and a few kB compressed, and +// at this resolution the encode is cheaper than pushing the raw bytes through a +// socket would be. +// +// quality is 1-100; returns an empty vector if the image is empty. +std::vector encode_jpeg(const Image &image, int quality = 70); + +} // namespace skysim::render diff --git a/src/render/raster.cpp b/src/render/raster.cpp new file mode 100644 index 0000000..9082f88 --- /dev/null +++ b/src/render/raster.cpp @@ -0,0 +1,251 @@ +#include "render/raster.h" + +#include "render/shading.h" + +#include +#include + +namespace skysim::render { +namespace { + +using core::Vec3; + +Vec3 sub(const Vec3 &a, const Vec3 &b) { return {a[0] - b[0], a[1] - b[1], a[2] - b[2]}; } + +Vec3 cross(const Vec3 &a, const Vec3 &b) { + return {a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]}; +} + +double dot(const Vec3 &a, const Vec3 &b) { return a[0] * b[0] + a[1] * b[1] + a[2] * b[2]; } + +Vec3 normalize(const Vec3 &v) { + const double n = std::sqrt(dot(v, v)); + return n > 0.0 ? Vec3{v[0] / n, v[1] / n, v[2] / n} : Vec3{0.0, 0.0, -1.0}; +} + +} // namespace + +void RasterScene::build(std::vector triangles) { + tris_ = std::move(triangles); + normals_.clear(); + normals_.reserve(tris_.size()); + cx_.clear(); + cy_.clear(); + cz_.clear(); + radius_.clear(); + cx_.reserve(tris_.size()); + cy_.reserve(tris_.size()); + cz_.reserve(tris_.size()); + radius_.reserve(tris_.size()); + for (const auto &t : tris_) { + normals_.push_back(normalize(cross(sub(t.b, t.a), sub(t.c, t.a)))); + const core::Vec3 c{(t.a[0] + t.b[0] + t.c[0]) / 3.0, (t.a[1] + t.b[1] + t.c[1]) / 3.0, + (t.a[2] + t.b[2] + t.c[2]) / 3.0}; + cx_.push_back(static_cast(c[0])); + cy_.push_back(static_cast(c[1])); + cz_.push_back(static_cast(c[2])); + const Vec3 ra = sub(t.a, c), rb = sub(t.b, c), rc = sub(t.c, c); + radius_.push_back( + static_cast(std::sqrt(std::max({dot(ra, ra), dot(rb, rb), dot(rc, rc)})))); + } +} + +Image Rasterizer::render(const RasterScene &scene, const Camera &camera, core::ThreadPool *pool) const { + Image image; + image.width = cfg_.width; + image.height = cfg_.height; + image.rgb.assign(static_cast(cfg_.width) * cfg_.height * 3, 0); + + std::vector tris; + transform(scene, camera, tris); + + // One band per worker. Each band's pixels belong to exactly one thread, so + // the depth buffer needs no synchronisation. + const int bands = pool != nullptr ? std::max(1, std::min(cfg_.height, 16)) : 1; + const auto do_band = [&](int band) { + const int y0 = cfg_.height * band / bands; + const int y1 = cfg_.height * (band + 1) / bands; + if (y0 >= y1) { + return; + } + draw_background(camera, image, y0, y1); + std::vector depth(static_cast(cfg_.width) * (y1 - y0), cfg_.max_range_m); + fill_band(tris, image, depth, y0, y1); + }; + + if (pool != nullptr) { + pool->parallel_for(static_cast(bands), [&](size_t begin, size_t end) { + for (size_t b = begin; b < end; ++b) { + do_band(static_cast(b)); + } + }); + } else { + do_band(0); + } + + return image; +} + +void Rasterizer::transform(const RasterScene &scene, const Camera &camera, std::vector &out) const { + const Vec3 eye = camera.position(); + // Camera basis in world space, taken from the rays the camera already knows + // how to make: centre ray is forward, and the frame is completed from the + // horizontal and vertical edges of the image. + const Vec3 fwd = camera.ray(cfg_.width / 2, cfg_.height / 2); + const Vec3 right = normalize(sub(camera.ray(cfg_.width - 1, cfg_.height / 2), camera.ray(0, cfg_.height / 2))); + const Vec3 down = normalize(sub(camera.ray(cfg_.width / 2, cfg_.height - 1), camera.ray(cfg_.width / 2, 0))); + + const double half_w = cfg_.width * 0.5; + const double half_h = cfg_.height * 0.5; + const double tan_half_x = std::tan((cfg_.fov_deg * M_PI / 180.0) * 0.5); + const double focal = half_w / tan_half_x; + + const double range_sq = cfg_.max_range_m * cfg_.max_range_m; + const auto &tris = scene.triangles(); + const auto &normals = scene.normals(); + out.clear(); + out.reserve(1024); + + // Read straight off the packed centroid arrays: this reject runs over the + // whole world every frame, so it is the one loop where the memory layout + // matters more than the arithmetic. + const float *cx = scene.centroid_x().data(); + const float *cy = scene.centroid_y().data(); + const float *cz = scene.centroid_z().data(); + const float *radius = scene.radius().data(); + const auto eye_x = static_cast(eye[0]); + const auto eye_y = static_cast(eye[1]); + const auto eye_z = static_cast(eye[2]); + const auto range_f = static_cast(std::sqrt(range_sq)); + + for (size_t i = 0; i < tris.size(); ++i) { + const float dx = cx[i] - eye_x; + const float dy = cy[i] - eye_y; + const float dz = cz[i] - eye_z; + // Against the triangle's near edge, not its middle: see RasterScene::radius(). + const float limit = range_f + radius[i]; + if (dx * dx + dy * dy + dz * dz > limit * limit) { + continue; + } + + const auto &t = tris[i]; + ScreenTriangle st{}; + bool behind = false; + for (int k = 0; k < 3; ++k) { + const Vec3 &p = k == 0 ? t.a : (k == 1 ? t.b : t.c); + const Vec3 v = sub(p, eye); + const double z = dot(v, fwd); + if (z <= 0.25) { // near plane; a proper clip is not worth it at this scale + behind = true; + break; + } + st.x[k] = static_cast(half_w + dot(v, right) * focal / z); + st.y[k] = static_cast(half_h + dot(v, down) * focal / z); + st.inv_w[k] = static_cast(1.0 / z); + } + if (behind) { + continue; + } + + // Off-screen reject. + const float min_x = std::min({st.x[0], st.x[1], st.x[2]}); + const float max_x = std::max({st.x[0], st.x[1], st.x[2]}); + const float min_y = std::min({st.y[0], st.y[1], st.y[2]}); + const float max_y = std::max({st.y[0], st.y[1], st.y[2]}); + if (max_x < 0 || min_x >= cfg_.width || max_y < 0 || min_y >= cfg_.height) { + continue; + } + + const Vec3 &n = normals[i]; + // Two-sided: a tile's winding is not guaranteed to face the camera, and a + // building lit from the inside out looks like a hole in the world. + st.shade = static_cast(shade_for_normal(n)); + st.is_ground = t.is_ground; + st.min_y = std::max(0, static_cast(std::floor(min_y))); + st.max_y = std::min(cfg_.height - 1, static_cast(std::ceil(max_y))); + out.push_back(st); + } +} + +void Rasterizer::draw_background(const Camera &camera, Image &image, int y0, int y1) const { + for (int y = y0; y < y1; ++y) { + for (int x = 0; x < cfg_.width; ++x) { + const Vec3 dir = camera.ray(x, y); + + // Below the horizon the background is ground haze, so terrain the + // triangles do not cover does not read as sky. + const bool below = dir[2] > 0.0; + const double *far = below ? kGround : kSkyZenith; + // Below the horizon this is only seen past the draw distance, so it + // sits close to the haze colour and lets the drawn ground stand out. + const double up = std::clamp(-dir[2], 0.0, 1.0); + const double blend = below ? 0.72 : std::pow(up, 0.65); + const size_t i = (static_cast(y) * cfg_.width + x) * 3; + for (int c = 0; c < 3; ++c) { + image.rgb[i + c] = to_byte(mix(kSkyHorizon[c], far[c], blend)); + } + } + } +} + +void Rasterizer::fill_band(const std::vector &tris, Image &image, std::vector &depth, + int y0, int y1) const { + for (const ScreenTriangle &t : tris) { + if (t.max_y < y0 || t.min_y >= y1) { + continue; // not in this band + } + + const float area = (t.x[1] - t.x[0]) * (t.y[2] - t.y[0]) - (t.x[2] - t.x[0]) * (t.y[1] - t.y[0]); + if (std::abs(area) < 1e-6f) { + continue; + } + const float inv_area = 1.0f / area; + + const int ys = std::max(y0, t.min_y); + const int ye = std::min(y1 - 1, t.max_y); + const int xs = std::max(0, static_cast(std::floor(std::min({t.x[0], t.x[1], t.x[2]})))); + const int xe = std::min(cfg_.width - 1, static_cast(std::ceil(std::max({t.x[0], t.x[1], t.x[2]})))); + + for (int y = ys; y <= ye; ++y) { + const float py = y + 0.5f; + for (int x = xs; x <= xe; ++x) { + const float px = x + 0.5f; + + // Barycentric coverage test. + const float w0 = ((t.x[1] - px) * (t.y[2] - py) - (t.x[2] - px) * (t.y[1] - py)) * inv_area; + const float w1 = ((t.x[2] - px) * (t.y[0] - py) - (t.x[0] - px) * (t.y[2] - py)) * inv_area; + const float w2 = 1.0f - w0 - w1; + if (w0 < 0.0f || w1 < 0.0f || w2 < 0.0f) { + continue; + } + + // Perspective-correct depth. + const float inv_w = w0 * t.inv_w[0] + w1 * t.inv_w[1] + w2 * t.inv_w[2]; + if (inv_w <= 0.0f) { + continue; + } + const float z = 1.0f / inv_w; + + const size_t di = static_cast(y - y0) * cfg_.width + x; + if (z >= depth[di]) { + continue; + } + depth[di] = z; + + const double *base = t.is_ground ? kGround : kBuilding; + // Haze grows with distance but never fully erases a surface: + // capped, so the far edge of the draw distance reads as far away + // rather than as blank sky. + const double haze = haze_at(z, cfg_.max_range_m); + + const size_t pi = (static_cast(y) * cfg_.width + x) * 3; + for (int c = 0; c < 3; ++c) { + const double lit = base[c] * t.shade; + image.rgb[pi + c] = to_byte(mix(lit, kSkyHorizon[c], haze)); + } + } + } + } +} + +} // namespace skysim::render diff --git a/src/render/raster.h b/src/render/raster.h new file mode 100644 index 0000000..2ac429e --- /dev/null +++ b/src/render/raster.h @@ -0,0 +1,95 @@ +#pragma once + +#include "core/thread_pool.h" +#include "core/world.h" +#include "render/camera.h" +#include "render/image.h" + +#include +#include + +namespace skysim::render { + +// A triangle already projected into screen space, ready to fill. +struct ScreenTriangle { + // Pixel coordinates, and 1/depth for perspective-correct interpolation. + float x[3]; + float y[3]; + float inv_w[3]; + float shade; // flat Lambert term, computed once per triangle + bool is_ground; + int min_y; + int max_y; +}; + +// Scene geometry in a form the rasteriser wants: triangles plus their +// precomputed normals, so the world is walked once rather than once per frame. +class RasterScene { + public: + // Takes ownership: the triangle list is ~24 MB on a city-sized world and + // copying it in was most of the memory this scene added. + void build(std::vector triangles); + + size_t triangle_count() const { return tris_.size(); } + + const std::vector &triangles() const { return tris_; } + const std::vector &normals() const { return normals_; } + + // Centroids, split by axis as floats. + // + // The range reject reads these for every triangle every frame — the one + // part of the frame that scales with the whole world rather than with what + // is in shot. Keeping them contiguous and narrow turns that scan from + // streaming 72-byte structs into three tight float arrays. + const std::vector ¢roid_x() const { return cx_; } + const std::vector ¢roid_y() const { return cy_; } + const std::vector ¢roid_z() const { return cz_; } + + // Distance from each centroid to its furthest vertex, read alongside them by + // the same reject: a triangle is only out of range once all of it is, or a + // building on the draw-distance boundary sheds the triangles whose middles + // fall outside and stands there with holes in it. + const std::vector &radius() const { return radius_; } + + private: + std::vector tris_; + std::vector normals_; + std::vector cx_, cy_, cz_, radius_; +}; + +// Draws the world by filling triangles instead of chasing a ray per pixel. +// +// The ray caster this replaces cost about 4.6 us per pixel, because every pixel +// walked the physics broadphase. The whole city is only ~330k triangles and a +// frame sees a few thousand of them, so filling those is a different order of +// cost entirely — the work stops scaling with resolution and starts scaling with +// what is actually in shot. +// +// Threading: the screen is cut into horizontal bands and each worker owns its +// band outright. A band's pixels and depth values are touched by exactly one +// thread, so there are no atomics and no locks in the inner loop — the parts +// that would otherwise serialise a rasteriser. +// +// Triangles are projected once, serially, then every band scans that list and +// skips what does not overlap its rows. Binning them per band up front would +// save the scan; at a few thousand visible triangles it has not been worth it. +class Rasterizer { + public: + explicit Rasterizer(const CameraConfig &cfg) : cfg_(cfg) {} + + Image render(const RasterScene &scene, const Camera &camera, core::ThreadPool *pool) const; + + private: + // Project and cull, producing screen triangles for the bands that need them. + void transform(const RasterScene &scene, const Camera &camera, std::vector &out) const; + + // Fill one horizontal band. Owns every pixel it writes. + void fill_band(const std::vector &tris, Image &image, std::vector &depth, int y0, + int y1) const; + + void draw_background(const Camera &camera, Image &image, int y0, int y1) const; + + CameraConfig cfg_; +}; + +} // namespace skysim::render diff --git a/src/render/render_service.cpp b/src/render/render_service.cpp new file mode 100644 index 0000000..bb49d5a --- /dev/null +++ b/src/render/render_service.cpp @@ -0,0 +1,87 @@ +#include "render/render_service.h" + +#include "render/jpeg.h" + +#include +#include + +namespace skysim::render { + +RenderService::RenderService(const Config &cfg) : cfg_(cfg) { + core::WorldConfig wcfg; + // This world is never stepped, so its timestep is irrelevant; it exists only + // to hold static geometry that rays can be cast at. + wcfg.dt_s = 1.0 / 800.0; + wcfg.worker_threads = 0; + world_ = std::make_unique(wcfg); + world_->add_ground_plane(); + if (!cfg_.tiles_dir.empty()) { + tiles_loaded_ = world_->load_tiles(cfg_.tiles_dir); + } + + // Pull the geometry out once. The static world does not change under this + // service — it loads its own tiles and never streams — so the per-frame cost + // is transforming what is in shot, not walking the world again. + scene_.build(world_->collect_static_triangles()); + + // The physics world was scaffolding: it parsed the tiles so the triangles + // could be lifted out. Nothing raycasts it once the rasteriser has the + // geometry, and keeping a second Jolt world alive costs ~10 MB for the life + // of the process. + world_.reset(); + + rasterizer_ = std::make_unique(cfg_.camera); + pool_ = std::make_unique(cfg_.threads); + thread_ = std::thread([this] { run(); }); +} + +RenderService::~RenderService() { + stop_.store(true, std::memory_order_relaxed); + if (thread_.joinable()) { + thread_.join(); + } +} + +void RenderService::publish_poses(std::vector poses) { + std::lock_guard lock(poses_mutex_); + poses_ = std::move(poses); +} + +void RenderService::run() { + using clock = std::chrono::steady_clock; + const auto period = std::chrono::duration(cfg_.fps > 0.0 ? 1.0 / cfg_.fps : 1.0); + + while (!stop_.load(std::memory_order_relaxed)) { + const auto started = clock::now(); + + std::vector poses; + { + std::lock_guard lock(poses_mutex_); + poses = poses_; + } + + for (const Pose &pose : poses) { + if (stop_.load(std::memory_order_relaxed)) { + break; + } + const Camera camera(cfg_.camera, pose.position_ned, pose.quat_ned_frd); + const Image image = rasterizer_->render(scene_, camera, pool_.get()); + auto jpeg = encode_jpeg(image, cfg_.quality); + if (!jpeg.empty()) { + frames_.publish(pose.vehicle_id, std::move(jpeg), 0.0); + } + } + + // Pace to the target rate. If a pass took longer than the period the + // sleep is skipped and the rate simply drops, which is the correct + // failure: frames get older, nothing else is affected. + const auto elapsed = clock::now() - started; + if (elapsed < period) { + std::this_thread::sleep_for(period - elapsed); + } else { + std::this_thread::sleep_for(std::chrono::milliseconds(1)); + } + } +} + +} // namespace skysim::render diff --git a/src/render/render_service.h b/src/render/render_service.h new file mode 100644 index 0000000..5f6f32e --- /dev/null +++ b/src/render/render_service.h @@ -0,0 +1,85 @@ +#pragma once + +#include "core/thread_pool.h" +#include "core/world.h" +#include "render/camera.h" +#include "render/frame_store.h" +#include "render/raster.h" +#include "render/image.h" + +#include +#include +#include +#include +#include +#include + +namespace skysim::render { + +// Renders camera frames on its own thread, against its own copy of the world. +// +// The obvious design — render on the tick thread — cannot work, and the numbers +// say so plainly: one row of a 256-wide frame costs about 1.2 ms of raycasting, +// and the entire 800 Hz physics tick budget is 1.25 ms. Drawing even a single +// row per tick spends the whole budget, and the first attempt at this (four rows +// a tick) had skysim freezing and thawing the vehicle tens of thousands of times +// while ArduPilot refused to arm for "Main loop slow". +// +// So this owns a second World, built from the same tile directory. Buildings and +// ground are static geometry loaded from files, so a private copy is not a copy +// of anything that changes — and it can be raycast freely while the real world +// steps, with no lock between them and no way for a slow frame to slow a vehicle. +// +// The cost is that other vehicles do not appear in the picture. That is the whole +// trade: ground, sky and buildings, drawn without ever touching the simulation. +class RenderService { + public: + struct Config { + CameraConfig camera; + std::string tiles_dir; + double fps{10.0}; + int quality{70}; + int threads{4}; + }; + + // One vehicle's pose, as the tick thread last published it. + struct Pose { + uint32_t vehicle_id{0}; + core::Vec3 position_ned{}; + core::Quat quat_ned_frd{1.0, 0.0, 0.0, 0.0}; + }; + + explicit RenderService(const Config &cfg); + ~RenderService(); + RenderService(const RenderService &) = delete; + RenderService &operator=(const RenderService &) = delete; + + // Called from the tick thread once per tick. Cheap: copies a handful of + // doubles under a mutex and returns. + void publish_poses(std::vector poses); + + // Latest encoded frame for a vehicle; empty until one has been drawn. + std::vector frame(uint32_t vehicle_id) const { return frames_.get(vehicle_id).jpeg; } + + size_t tiles_loaded() const { return tiles_loaded_; } + size_t triangle_count() const { return scene_.triangle_count(); } + + private: + void run(); + + Config cfg_; + std::unique_ptr world_; + std::unique_ptr rasterizer_; + RasterScene scene_; + std::unique_ptr pool_; + FrameStore frames_; + size_t tiles_loaded_{0}; + + mutable std::mutex poses_mutex_; + std::vector poses_; + + std::atomic stop_{false}; + std::thread thread_; +}; + +} // namespace skysim::render diff --git a/src/render/renderer.cpp b/src/render/renderer.cpp new file mode 100644 index 0000000..c7a70a0 --- /dev/null +++ b/src/render/renderer.cpp @@ -0,0 +1,89 @@ +#include "render/renderer.h" + +#include "render/shading.h" + +#include +#include + +namespace skysim::render { +namespace { + +using core::Vec3; + +} // namespace + +Image Renderer::render(const core::World &world, uint32_t vehicle_id, const core::Vec3 &position_ned, + const core::Quat &quat_ned_frd, core::ThreadPool *pool) const { + Image image; + image.width = cfg_.width; + image.height = cfg_.height; + image.rgb.assign(static_cast(cfg_.width) * cfg_.height * 3, 0); + + const Camera camera(cfg_, position_ned, quat_ned_frd); + + if (pool != nullptr) { + pool->parallel_for(static_cast(cfg_.height), [&](size_t begin, size_t end) { + render_rows(world, camera, vehicle_id, image, static_cast(begin), static_cast(end)); + }); + } else { + render_rows(world, camera, vehicle_id, image, 0, cfg_.height); + } + + return image; +} + +void Renderer::render_rows(const core::World &world, const Camera &camera, uint32_t vehicle_id, Image &image, + int row_begin, int row_end) const { + // Height of the camera above the ground plane. NED z is down, so a camera + // 60 m up has z = -60. + const double height = -camera.position()[2]; + + for (int y = row_begin; y < row_end; ++y) { + for (int x = 0; x < cfg_.width; ++x) { + const Vec3 dir = camera.ray(x, y); + + // Where this ray meets the ground plane, analytically. Most rays in a + // downward-looking camera end there, and casting the full draw + // distance through a city to discover that is most of the frame time: + // the cast only has to reach as far as the ground. + double ground_dist = -1.0; + if (dir[2] > 1e-6 && height > 0.0) { + ground_dist = height / dir[2]; + } + + double cast_dist = cfg_.max_range_m; + if (ground_dist > 0.0 && ground_dist < cast_dist) { + cast_dist = ground_dist; + } + + core::World::RayHit hit = world.raycast_surface(camera.position(), dir, cast_dist, vehicle_id); + + // Nothing solid within the shortened ray, but the ray still meets the + // ground at the end of it. + if (!hit.hit && ground_dist > 0.0 && ground_dist <= cfg_.max_range_m) { + hit.hit = true; + hit.distance = ground_dist; + hit.normal_ned = {0.0, 0.0, -1.0}; // ground faces up; NED up is -z + } + + double colour[3]; + if (!hit.hit) { + sky_colour(dir, colour); + } else { + const double *base = hit.is_ground ? kGround : kBuilding; + const double light = shade_for_normal(hit.normal_ned); + const double haze = haze_at(hit.distance, cfg_.max_range_m); + for (int c = 0; c < 3; ++c) { + colour[c] = mix(base[c] * light, kSkyHorizon[c], haze); + } + } + + const size_t i = (static_cast(y) * cfg_.width + x) * 3; + for (int c = 0; c < 3; ++c) { + image.rgb[i + c] = to_byte(colour[c]); + } + } + } +} + +} // namespace skysim::render diff --git a/src/render/renderer.h b/src/render/renderer.h new file mode 100644 index 0000000..6cf14b1 --- /dev/null +++ b/src/render/renderer.h @@ -0,0 +1,48 @@ +#pragma once + +#include "core/thread_pool.h" +#include "core/world.h" +#include "render/camera.h" +#include "render/image.h" + +#include +#include + +namespace skysim::render { + +// Draws the world the way the physics already knows it. +// +// NOT the production renderer — Rasterizer is, and is ~17x faster. This is kept +// deliberately, as the reference the rasteriser is checked against: it asks the +// collision world directly, one ray per pixel, so it cannot disagree with the +// geometry a vehicle will actually hit. tests/test_render.cpp holds both to the +// same facts. Delete it only along with that comparison. +// +// This is a ray caster rather than a rasteriser, and that is the whole trick: +// skysim already holds every building as collision geometry, and already +// answers "what does this ray hit" fast enough to fly rangefinders off. Casting +// one ray per pixel reuses that exact geometry, so the picture cannot disagree +// with what the vehicle will crash into — no second copy of the world to build, +// load or keep in step. +// +// The cost is per-pixel rather than per-triangle, which is why the resolution is +// deliberately small and the rows are spread across the pool. +class Renderer { + public: + explicit Renderer(const CameraConfig &cfg) : cfg_(cfg) {} + + const CameraConfig &config() const { return cfg_; } + + // Render the view from `vehicle_id`'s airframe. The vehicle's own collision + // box is excluded, or every ray would hit it at zero range. + Image render(const core::World &world, uint32_t vehicle_id, const core::Vec3 &position_ned, + const core::Quat &quat_ned_frd, core::ThreadPool *pool = nullptr) const; + + private: + void render_rows(const core::World &world, const Camera &camera, uint32_t vehicle_id, Image &image, int row_begin, + int row_end) const; + + CameraConfig cfg_; +}; + +} // namespace skysim::render diff --git a/src/render/shading.h b/src/render/shading.h new file mode 100644 index 0000000..2621fc4 --- /dev/null +++ b/src/render/shading.h @@ -0,0 +1,65 @@ +#pragma once + +#include "core/frames.h" + +#include +#include +#include + +namespace skysim::render { + +// What the world looks like, in one place. +// +// Two renderers draw this world — the rasteriser that ships, and the ray caster +// the tests use as a reference — and a picture that changes depending on which +// one drew it is worse than either. These constants were duplicated in both +// files, in different types, kept equal by a comment saying they were equal. +// They had already drifted. + +//: Late afternoon, high and off to one side, so vertical faces of a building are +//: lit differently from each other and the block reads as solid rather than flat. +//: Points from the surface towards the sun. +inline constexpr core::Vec3 kSunDirNed{-0.45, -0.35, -0.82}; + +inline constexpr double kSkyHorizon[3] = {0.72, 0.80, 0.90}; +inline constexpr double kSkyZenith[3] = {0.25, 0.47, 0.80}; +inline constexpr double kGround[3] = {0.35, 0.42, 0.27}; +inline constexpr double kBuilding[3] = {0.62, 0.60, 0.57}; + +//: Light a surface gets regardless of which way it faces. +inline constexpr double kAmbient = 0.42; + +//: Haze never fully erases a surface, so the far edge of the draw distance reads +//: as far away rather than as blank sky. +inline constexpr double kMaxHaze = 0.82; + +inline uint8_t to_byte(double v) { return static_cast(std::clamp(v, 0.0, 1.0) * 255.0 + 0.5); } + +inline double mix(double a, double b, double t) { return a + (b - a) * t; } + +//: How lit a surface is. Two-sided: a tile's winding is not guaranteed to face +//: the camera, and a building lit from the inside out looks like a hole. +inline double shade_for_normal(const core::Vec3 &n) { + const double lambert = + std::abs(n[0] * kSunDirNed[0] + n[1] * kSunDirNed[1] + n[2] * kSunDirNed[2]); + return kAmbient + (1.0 - kAmbient) * std::clamp(lambert, 0.0, 1.0); +} + +//: Sky colour for a ray direction. NED z is down, so a ray at the sky has z < 0. +//: Biased towards the horizon colour so the band near eye level stays pale, which +//: is what makes a horizon read as one. +inline void sky_colour(const core::Vec3 &dir, double out[3]) { + const double up = std::clamp(-dir[2], 0.0, 1.0); + const double t = std::pow(up, 0.65); + for (int c = 0; c < 3; ++c) { + out[c] = mix(kSkyHorizon[c], kSkyZenith[c], t); + } +} + +//: How much haze sits between the camera and something this far away. +inline double haze_at(double distance_m, double max_range_m) { + const double fog = std::clamp(distance_m / max_range_m, 0.0, 1.0); + return std::min(fog * fog, kMaxHaze); +} + +} // namespace skysim::render diff --git a/tests/test_render.cpp b/tests/test_render.cpp new file mode 100644 index 0000000..147357a --- /dev/null +++ b/tests/test_render.cpp @@ -0,0 +1,211 @@ +// Camera render acceptance: put one real cooked building in a world, point a +// camera at it, and check the picture says what the world says — sky above the +// horizon, ground below it, and the building where the building is. +// +// Asserting on pixels rather than on "it produced bytes" is the point: a renderer +// that silently draws a blank frame, or draws the world upside down, still +// produces a valid JPEG. +#include +#include +#include +#include +#include + +#include "core/world.h" +#include "render/jpeg.h" +#include "render/raster.h" +#include "render/renderer.h" +#include "terrain/cook.h" + +namespace { + +int g_failures = 0; + +#define CHECK(cond) \ + do { \ + if (!(cond)) { \ + std::printf("FAIL %s:%d: %s\n", __FILE__, __LINE__, #cond); \ + ++g_failures; \ + } \ + } while (0) + +// Same box the collision test uses: NED north 30..70, east -20..20, roof at z=-30. +void write_test_obj(const std::filesystem::path &path) { + std::ofstream f(path); + const double x0 = -20, x1 = 20, y0 = 30, y1 = 70, z0 = 0, z1 = 30; + f << "v " << x0 << " " << y0 << " " << z0 << "\nv " << x1 << " " << y0 << " " << z0 << "\n" + << "v " << x1 << " " << y1 << " " << z0 << "\nv " << x0 << " " << y1 << " " << z0 << "\n" + << "v " << x0 << " " << y0 << " " << z1 << "\nv " << x1 << " " << y0 << " " << z1 << "\n" + << "v " << x1 << " " << y1 << " " << z1 << "\nv " << x0 << " " << y1 << " " << z1 << "\n"; + const int quads[6][4] = {{1, 2, 6, 5}, {2, 3, 7, 6}, {3, 4, 8, 7}, + {4, 1, 5, 8}, {5, 6, 7, 8}, {4, 3, 2, 1}}; + for (const auto &q : quads) { + f << "f " << q[0] << " " << q[1] << " " << q[2] << "\n"; + f << "f " << q[0] << " " << q[2] << " " << q[3] << "\n"; + } +} + +skysim::core::WorldConfig cfg() { + skysim::core::WorldConfig c; + c.dt_s = 1.0 / 800.0; + c.worker_threads = 2; + return c; +} + +struct Px { + int r, g, b; +}; + +Px pixel(const skysim::render::Image &img, int x, int y) { + const size_t i = (static_cast(y) * img.width + x) * 3; + return {img.rgb[i], img.rgb[i + 1], img.rgb[i + 2]}; +} + +// Sky is the only thing in the palette that is markedly more blue than red. +bool looks_like_sky(const Px &p) { return p.b > p.r + 20; } + +// Ground is the only thing in the palette with a green cast; building grey has +// its three channels within a few counts of each other. +bool looks_like_ground(const Px &p) { return p.g > p.b + 12; } + +} // namespace + +int main() { + const auto dir = std::filesystem::temp_directory_path() / "skysim_render_tiles"; + std::filesystem::create_directories(dir); + const auto obj = dir / "building.obj"; + write_test_obj(obj); + + std::string err; + if (!skysim::terrain::cook_obj_tile(obj, dir / "building.jshape", nullptr, &err)) { + std::printf("FAIL cook: %s\n", err.c_str()); + return 1; + } + + skysim::core::World w(cfg()); + w.add_ground_plane(); + CHECK(w.load_tiles(dir) == 1); + + skysim::render::CameraConfig cam; + cam.width = 128; + cam.height = 72; + cam.pitch_deg = 0.0; // level, so the horizon lands across the middle + const skysim::render::Renderer renderer(cam); + + // Level, facing north (identity attitude), 20 m up, well south of the building. + const skysim::core::Vec3 pos{-60.0, 0.0, -20.0}; + const skysim::core::Quat level{1.0, 0.0, 0.0, 0.0}; + + const skysim::render::Image img = renderer.render(w, 0, pos, level, nullptr); + CHECK(img.width == cam.width); + CHECK(img.height == cam.height); + CHECK(img.rgb.size() == static_cast(cam.width) * cam.height * 3); + + // --- The world is the right way up. --- + // A level camera 20 m up sees sky along the top edge and ground along the + // bottom. Upside down, or with the pitch sign flipped, this fails. + CHECK(looks_like_sky(pixel(img, cam.width / 2, 1))); + CHECK(!looks_like_sky(pixel(img, cam.width / 2, cam.height - 2))); + + // --- The building is in front, and it is not sky. --- + // Straight ahead at eye level the ray runs into the box's south wall. + const Px ahead = pixel(img, cam.width / 2, cam.height / 2 - 1); + CHECK(!looks_like_sky(ahead)); + + // --- Turning away from it shows sky at the same pixel. --- + // Yaw 180 degrees: nothing behind but empty world, so the centre row above + // the horizon must be sky. This is what catches a camera that ignores + // attitude entirely and always renders the same frame. + const skysim::core::Quat about_face{0.0, 0.0, 0.0, 1.0}; // 180 deg about NED down + const skysim::render::Image behind = renderer.render(w, 0, pos, about_face, nullptr); + CHECK(looks_like_sky(pixel(behind, cam.width / 2, cam.height / 2 - 1))); + + // --- Ground gets nearer as you look further down. --- + // Bottom of the frame is closer ground than the middle, so it fogs less and + // stays darker than the horizon haze. + { + skysim::render::CameraConfig down = cam; + down.pitch_deg = -60.0; + const skysim::render::Renderer looking_down(down); + const skysim::render::Image g = looking_down.render(w, 0, pos, level, nullptr); + for (int x = 0; x < g.width; x += 16) { + CHECK(!looks_like_sky(pixel(g, x, g.height - 2))); + } + } + + // --- A flat roof is not a lawn. --- + // + // A roof normal points straight up, exactly like the ground's, so a renderer + // that decides "ground or building" from the normal turns every rooftop in + // the city into grass. Both renderers have to get this from the geometry's + // provenance instead. + // + // Camera 50 m above the roof and 29 m south of its centre, pitched 60 deg + // down: the centre ray clears the south wall and lands on the roof. + { + skysim::render::CameraConfig over = cam; + over.pitch_deg = -60.0; + const skysim::core::Vec3 above{21.0, 0.0, -80.0}; + + const skysim::render::Image roof = skysim::render::Renderer(over).render(w, 0, above, level, nullptr); + CHECK(!looks_like_sky(pixel(roof, over.width / 2, over.height / 2))); + CHECK(!looks_like_ground(pixel(roof, over.width / 2, over.height / 2))); + + skysim::render::RasterScene scene; + scene.build(w.collect_static_triangles()); + const skysim::render::Image r = + skysim::render::Rasterizer(over).render(scene, skysim::render::Camera(over, above, level), nullptr); + CHECK(!looks_like_sky(pixel(r, over.width / 2, over.height / 2))); + CHECK(!looks_like_ground(pixel(r, over.width / 2, over.height / 2))); + + // And the actual ground still reads as ground, or the check above would + // pass just as well on a renderer that had stopped drawing grass at all. + CHECK(looks_like_ground(pixel(img, cam.width / 2, cam.height - 2))); + } + + // --- It encodes, and to something a decoder will accept. --- + const std::vector jpeg = skysim::render::encode_jpeg(img, 70); + CHECK(jpeg.size() > 256); + CHECK(jpeg.size() < img.rgb.size()); // compression actually happened + CHECK(jpeg[0] == 0xFF && jpeg[1] == 0xD8); // SOI + CHECK(jpeg[jpeg.size() - 2] == 0xFF && jpeg[jpeg.size() - 1] == 0xD9); // EOI + + // --- An empty image encodes to nothing rather than crashing. --- + CHECK(skysim::render::encode_jpeg(skysim::render::Image{}, 70).empty()); + + // --- The rasteriser draws the same world as the ray caster. --- + // + // It is a different algorithm against the same geometry, so it is checked + // against the same facts rather than against the other renderer's pixels: + // sky up, ground down, building ahead, sky when you turn away. + { + skysim::render::RasterScene scene; + scene.build(w.collect_static_triangles()); + CHECK(scene.triangle_count() > 0); // 0 means the extraction found nothing + + const skysim::render::Rasterizer raster(cam); + + const skysim::render::Image r = raster.render(scene, skysim::render::Camera(cam, pos, level), nullptr); + CHECK(r.width == cam.width && r.height == cam.height); + CHECK(looks_like_sky(pixel(r, cam.width / 2, 1))); + CHECK(!looks_like_sky(pixel(r, cam.width / 2, cam.height - 2))); + CHECK(!looks_like_sky(pixel(r, cam.width / 2, cam.height / 2 - 1))); // the building + + const skysim::render::Image back = + raster.render(scene, skysim::render::Camera(cam, pos, about_face), nullptr); + CHECK(looks_like_sky(pixel(back, cam.width / 2, cam.height / 2 - 1))); + + // Threaded and serial must agree exactly: each band owns its pixels, so + // splitting the work cannot change a single one of them. + skysim::core::ThreadPool pool(4); + const skysim::render::Image threaded = + raster.render(scene, skysim::render::Camera(cam, pos, level), &pool); + CHECK(threaded.rgb == r.rgb); + } + + std::filesystem::remove_all(dir); + if (g_failures == 0) { + std::printf("test_render: OK\n"); + } + return g_failures == 0 ? 0 : 1; +} diff --git a/tools/getting_started.sh b/tools/getting_started.sh new file mode 100755 index 0000000..fc17390 --- /dev/null +++ b/tools/getting_started.sh @@ -0,0 +1,150 @@ +#!/usr/bin/env bash +# See skysim work in about a minute, without building ArduPilot first. +# +# Getting started with a lockstep physics server usually means building an +# autopilot, cooking a world and wiring three processes together before anything +# happens on screen. That is a long way to go to find out whether the thing +# builds. This cooks a small city, renders it from a few camera poses, and drops +# the images in one directory — no SITL, no network, no autopilot. +# +# If ARDUPILOT_ROOT is set it goes on to fly a real vehicle through that same +# world, so the second half only costs you anything once you want it. +# +# tools/getting_started.sh # render only +# ARDUPILOT_ROOT=~/ardupilot tools/getting_started.sh --fly +set -euo pipefail + +cd "$(dirname "$0")/.." + +BUILD_DIR="${BUILD_DIR:-build}" +OUT_DIR="${OUT_DIR:-$BUILD_DIR/getting_started}" +# Its own world rather than build/demo_map, which the quick start above cooks at +# the default extent: this one wants a city big enough to have a horizon. +WORLD_OBJ="$OUT_DIR/world_obj" +WORLD_TILES="$OUT_DIR/world/tiles" +FLY=0 +[ "${1:-}" = "--fly" ] && FLY=1 +# Overridable because a machine already running skysim owns the default ports, +# and "getting started" should not mean "first stop whatever you were doing". +INSTANCE="${INSTANCE:-0}" +API_PORT="${API_PORT:-8642}" + +say() { printf '\n\033[1m%s\033[0m\n' "$*"; } + +# --- 1. Build ----------------------------------------------------------------- +if [ ! -x "$BUILD_DIR/skysim" ] || [ ! -x "$BUILD_DIR/render_probe" ]; then + say "Building skysim" + cmake -B "$BUILD_DIR" -G Ninja -DCMAKE_BUILD_TYPE=RelWithDebInfo >/dev/null + cmake --build "$BUILD_DIR" -j +fi + +# --- 2. Cook a world ---------------------------------------------------------- +# Skipped when the tiles are already there: cooking is deterministic and the +# whole point of this script is that re-running it is cheap. +if [ ! -d "$WORLD_TILES" ] || [ -z "$(ls -A "$WORLD_TILES" 2>/dev/null)" ]; then + say "Cooking a demo city" + # 1.2 km across, in 300 m tiles. Big enough that the far blocks sit out at the + # draw distance and fade, which is the only way to see that the haze is doing + # anything; the default 250 m map fits inside one clear view. + python3 tools/cooker/pretile.py "$WORLD_OBJ" --extent 600 --tile-size 300 + ./"$BUILD_DIR"/tile_cooker "$WORLD_TILES" "$WORLD_OBJ"/*.obj +fi +TILE_COUNT=$(ls -1 "$WORLD_TILES"/*.jshape 2>/dev/null | wc -l) + +# --- 3. Render it ------------------------------------------------------------- +say "Rendering the world from four camera poses" +mkdir -p "$OUT_DIR" +rm -f "$OUT_DIR"/*.jpg # or renaming a pose leaves the old one behind, listed as if it were fresh + +# Four poses that between them show the whole palette: a building from below, a +# skyline at roof height, the grid from above, and a long view into the haze. +# All of them stand off the origin, which pretile.py deliberately leaves clear +# for takeoff — parked there the camera looks down an empty street. +render() { # name north east alt pitch yaw + ./"$BUILD_DIR"/render_probe \ + --tiles "$WORLD_TILES" --raster --threads 4 --size 640x360 \ + --north "$2" --east "$3" --alt "$4" --pitch "$5" --yaw "$6" \ + --out "$OUT_DIR/$1.jpg" | tail -1 +} +render 1-street-level -160 -160 9 -3 45 +render 2-skyline -320 -320 55 -10 40 +render 3-avenue -500 0 14 -2 0 +render 4-long-view -520 -520 120 -12 38 + +# --- 4. Optionally fly through it --------------------------------------------- +if [ "$FLY" = "1" ]; then + if [ -z "${ARDUPILOT_ROOT:-}" ]; then + echo " --fly needs ARDUPILOT_ROOT set to an ArduPilot checkout built for SITL" >&2 + exit 2 + fi + + say "Starting skysim + one vehicle (Ctrl-C to stop)" + LOG="$OUT_DIR/fly.log" + SITL_PID="" # named in the trap below before it is ever assigned + # 400 Hz physics against a 200 Hz autopilot scheduler: ArduPilot will not arm + # unless the gyro rate is at least 1.8x its loop rate, and on the JSON + # backend the gyro rate is the physics rate. + ./"$BUILD_DIR"/skysim \ + --vehicles 1 --base-instance "$INSTANCE" --dt 0.0025 --time-mode interactive \ + --tiles "$WORLD_TILES" --api-port "$API_PORT" \ + --camera-fps 10 --camera-size 320x180 >"$LOG" 2>&1 & + SKYSIM_PID=$! + trap 'kill $SKYSIM_PID $SITL_PID 2>/dev/null || true' EXIT + + # Poll for the thing we actually need, rather than sleeping a guessed number + # of seconds at it. A fixed sleep reports a camera URL for a process that + # died on a port clash thirty seconds ago. + # + # give_up_after tenths, pid to watch, message, then the test as extra args. + wait_for() { + local tries="$1" pid="$2" what="$3" + shift 3 + for _ in $(seq "$tries"); do + if "$@"; then return 0; fi + if ! kill -0 "$pid" 2>/dev/null; then + break + fi + sleep 0.1 + done + echo >&2 + sed 's/^/ /' "$LOG" >&2 + echo >&2 " $what" + exit 1 + } + + api_up() { curl -sf "http://127.0.0.1:$API_PORT/vehicles" >/dev/null; } + vehicle_connected() { curl -sf "http://127.0.0.1:$API_PORT/vehicles" | grep -q '"connected":true'; } + + wait_for 100 $SKYSIM_PID \ + "skysim never came up. If a port is taken: INSTANCE=1 API_PORT=8643 $0 --fly" api_up + + # --serial0 tcp:0 or the autopilot blocks on "Waiting for connection ...." + # for a ground station that is never coming, and never sends a servo packet. + "$ARDUPILOT_ROOT/build/sitl/bin/arducopter" --model json:127.0.0.1 -I "$INSTANCE" \ + --home 42.1403890,24.7645490,0,0 \ + --defaults "$ARDUPILOT_ROOT/Tools/autotest/default_params/copter.parm" \ + --serial0 tcp:0 --sim-address 127.0.0.1 >>"$LOG" 2>&1 & + SITL_PID=$! + + wait_for 300 $SITL_PID \ + "the autopilot never completed the JSON handshake (is instance $INSTANCE already flying?)" \ + vehicle_connected + + say "Flying. The vehicle's camera is live at:" + echo " http://127.0.0.1:$API_PORT/instances/$INSTANCE/camera.mjpg (open in a browser)" + echo " http://127.0.0.1:$API_PORT/vehicles (state)" + echo " http://127.0.0.1:$API_PORT/metrics (tick timing)" + echo " MAVLink: 127.0.0.1:$((5760 + 10 * INSTANCE)) (connect QGroundControl or SkyHub)" + wait $SKYSIM_PID + exit 0 +fi + +# --- 5. Say what happened ----------------------------------------------------- +say "Done — $TILE_COUNT tiles, 4 images in $OUT_DIR" +ls -1 "$OUT_DIR"/*.jpg | sed 's/^/ /' +cat < +#include +#include +#include +#include +#include +#include +#include + +#include "core/thread_pool.h" +#include "core/world.h" +#include "render/jpeg.h" +#include "render/raster.h" +#include "render/renderer.h" + +namespace { + +struct Options { + std::string tiles; + std::string out = "frame.jpg"; + double north = 0.0; + double east = 0.0; + double alt = 40.0; // metres above the ground plane + double yaw_deg = 0.0; + double pitch_deg = -15.0; + int width = 320; + int height = 180; + double fov_deg = 78.0; + int quality = 80; + int repeat = 1; // >1 reports render cost per frame + int threads = 0; // render worker threads; 0 = serial + bool raster = false; // use the rasteriser instead of the ray caster +}; + +[[noreturn]] void usage(const char *why) { + std::fprintf(stderr, "render_probe: %s\n", why); + std::fprintf(stderr, "usage: render_probe --tiles DIR [--out F] [--north M] [--east M] [--alt M]\n" + " [--yaw DEG] [--pitch DEG] [--size WxH] [--fov DEG]\n"); + std::exit(2); +} + +Options parse(int argc, char **argv) { + Options o; + for (int i = 1; i < argc; ++i) { + auto value = [&](const char *flag) -> const char * { + if (i + 1 >= argc) { + usage(flag); + } + return argv[++i]; + }; + if (std::strcmp(argv[i], "--tiles") == 0) { + o.tiles = value("--tiles"); + } else if (std::strcmp(argv[i], "--out") == 0) { + o.out = value("--out"); + } else if (std::strcmp(argv[i], "--north") == 0) { + o.north = std::atof(value("--north")); + } else if (std::strcmp(argv[i], "--east") == 0) { + o.east = std::atof(value("--east")); + } else if (std::strcmp(argv[i], "--alt") == 0) { + o.alt = std::atof(value("--alt")); + } else if (std::strcmp(argv[i], "--yaw") == 0) { + o.yaw_deg = std::atof(value("--yaw")); + } else if (std::strcmp(argv[i], "--pitch") == 0) { + o.pitch_deg = std::atof(value("--pitch")); + } else if (std::strcmp(argv[i], "--raster") == 0) { + o.raster = true; + } else if (std::strcmp(argv[i], "--threads") == 0) { + o.threads = std::atoi(value("--threads")); + } else if (std::strcmp(argv[i], "--repeat") == 0) { + o.repeat = std::atoi(value("--repeat")); + } else if (std::strcmp(argv[i], "--fov") == 0) { + o.fov_deg = std::atof(value("--fov")); + } else if (std::strcmp(argv[i], "--size") == 0) { + const char *v = value("--size"); + const char *x = std::strchr(v, 'x'); + o.width = std::atoi(v); + o.height = (x != nullptr) ? std::atoi(x + 1) : 0; + } else { + usage(argv[i]); + } + } + if (o.tiles.empty()) { + usage("--tiles is required"); + } + if (o.width <= 0 || o.height <= 0) { + usage("--size must be WxH with both > 0"); + } + return o; +} + +} // namespace + +int main(int argc, char **argv) { + const Options o = parse(argc, argv); + + skysim::core::WorldConfig wcfg; + wcfg.dt_s = 1.0 / 800.0; + wcfg.worker_threads = 0; + skysim::core::World world(wcfg); + world.add_ground_plane(); + const size_t tiles = world.load_tiles(o.tiles); + std::printf("render_probe: %zu tile(s) from %s\n", tiles, o.tiles.c_str()); + + // Yaw only: level flight, which is what a probe of a static world wants. + const double half = (o.yaw_deg * M_PI / 180.0) * 0.5; + const skysim::core::Quat attitude{std::cos(half), 0.0, 0.0, std::sin(half)}; + + skysim::render::CameraConfig cfg; + cfg.width = o.width; + cfg.height = o.height; + cfg.fov_deg = o.fov_deg; + cfg.pitch_deg = o.pitch_deg; + const skysim::render::Renderer renderer(cfg); + + const skysim::core::Vec3 pos{o.north, o.east, -o.alt}; + skysim::core::ThreadPool pool(o.threads); + + skysim::render::RasterScene scene; + const skysim::render::Rasterizer raster(cfg); + if (o.raster) { + const auto tris = world.collect_static_triangles(); + scene.build(tris); + std::printf("render_probe: %zu triangle(s) in the scene\n", scene.triangle_count()); + } + const skysim::render::Camera camera(cfg, pos, attitude); + + const auto t0 = std::chrono::steady_clock::now(); + skysim::render::Image image; + for (int i = 0; i < std::max(1, o.repeat); ++i) { + image = o.raster ? raster.render(scene, camera, &pool) : renderer.render(world, 0, pos, attitude, &pool); + } + const auto t1 = std::chrono::steady_clock::now(); + if (o.repeat > 1) { + const double ms = std::chrono::duration(t1 - t0).count() / o.repeat; + std::printf("render_probe: %.2f ms/frame with %d thread(s) (%.1f fps) at %dx%d\n", ms, o.threads, + 1000.0 / ms, image.width, image.height); + } + const auto jpeg = skysim::render::encode_jpeg(image, o.quality); + if (jpeg.empty()) { + std::fprintf(stderr, "render_probe: encode failed\n"); + return 1; + } + + std::ofstream f(o.out, std::ios::binary); + f.write(reinterpret_cast(jpeg.data()), static_cast(jpeg.size())); + std::printf("render_probe: wrote %s (%zu bytes, %dx%d)\n", o.out.c_str(), jpeg.size(), image.width, image.height); + return 0; +} From 551ea8c62916442bb9ca7d0b976835c28c2dc4e5 Mon Sep 17 00:00:00 2001 From: yallex Date: Sun, 9 Aug 2026 07:58:28 +0300 Subject: [PATCH 2/4] Cover the render service and the camera endpoints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The coverage gate caught what the tests did not: RenderService and FrameStore were at 0%, which is to say the thing that actually renders in flight was never run by a test. Both its failure modes are silent — a world that loads no geometry, and a thread that never produces a frame — and both look from outside like a working server serving nothing. So: the service loads its tiles, publishes a pose, and has to produce a real JPEG for that vehicle and nothing for any other, with the destructor joining its thread rather than hanging. FrameStore keeps one frame per vehicle, replaced whole. The four camera endpoints are exercised through the real HTTP stack for both the camera-off and camera-on cases, including instance-to-id resolution and the two ways a request can legitimately find no picture. Back over the gates: lines 80.7 -> 86.5%, functions 87.4 -> 96.0%, branches 66.4 -> 72.8%. Also drops a comment in frame_store.h that still described rendering as happening on the tick thread. It moved off it precisely because that starved the physics. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_017cgM68QE3FDz7S2pQAfTaZ --- src/render/frame_store.h | 10 +++--- tests/test_api.cpp | 66 ++++++++++++++++++++++++++++++++++++++++ tests/test_render.cpp | 64 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 134 insertions(+), 6 deletions(-) diff --git a/src/render/frame_store.h b/src/render/frame_store.h index 3bda577..1540fa8 100644 --- a/src/render/frame_store.h +++ b/src/render/frame_store.h @@ -7,13 +7,11 @@ namespace skysim::render { -// Latest camera frame per vehicle, handed from the tick thread to the HTTP thread. +// Latest camera frame per vehicle, handed from the render thread to the HTTP threads. // -// Rendering happens on the tick thread because casting rays into the physics -// world while it is stepping is not safe, and because the server's whole -// threading contract is that the HTTP side only ever reads what a tick -// published. This is that publication: one encoded JPEG per vehicle, replaced -// whole, copied out under the lock. +// The two sides run at unrelated rates — one draws when it can, the others serve +// whoever is connected — so the handover is a slot rather than a channel: one +// encoded JPEG per vehicle, replaced whole, copied out under the lock. // // Frames are dropped rather than queued. A viewer that falls behind wants the // newest picture, not a backlog of stale ones. diff --git a/tests/test_api.cpp b/tests/test_api.cpp index f87ffea..ccbbcc3 100644 --- a/tests/test_api.cpp +++ b/tests/test_api.cpp @@ -24,6 +24,7 @@ int g_failures = 0; } while (0) constexpr int kPort = 18642; +constexpr int kCameraPort = 18643; } // namespace @@ -237,6 +238,71 @@ int main() { CHECK(metrics && metrics->body.find("\"freezes\":1") != std::string::npos); } // ~ControlServer stops + joins the HTTP thread + // --- Camera endpoints, camera off. --- + // + // No camera_frame callback is the normal headless configuration, not an error + // state, so the endpoints have to exist and refuse politely rather than 500. + { + ControlServer server("127.0.0.1", kCameraPort, queue, snaps); + httplib::Client client("127.0.0.1", kCameraPort); + client.set_read_timeout(15, 0); + + auto off = client.Get("/vehicles/1/camera.jpg"); + CHECK(off && off->status == 404); + CHECK(off && off->body.find("camera disabled") != std::string::npos); + CHECK(client.Get("/vehicles/1/camera.mjpg")->status == 404); + CHECK(client.Get("/instances/30/camera.jpg")->status == 404); + CHECK(client.Get("/instances/30/camera.mjpg")->status == 404); + } + + // --- Camera endpoints, camera on. --- + { + // Not a real JPEG: these routes move bytes and pick status codes, and + // whether those bytes decode is test_render's business. + const std::vector canned{0xFF, 0xD8, 'p', 'i', 'x', 0xFF, 0xD9}; + ControlServer::Snapshots with_camera = snaps; + with_camera.camera_frame = [canned](uint32_t id) { + return id == 1 ? canned : std::vector{}; + }; + + ControlServer server("127.0.0.1", kCameraPort, queue, with_camera); + httplib::Client client("127.0.0.1", kCameraPort); + client.set_read_timeout(15, 0); + + auto shot = client.Get("/vehicles/1/camera.jpg"); + CHECK(shot && shot->status == 200); + CHECK(shot && shot->get_header_value("Content-Type") == "image/jpeg"); + CHECK(shot && shot->body.size() == canned.size()); + + // A vehicle that exists but has not been drawn yet is not an error the + // caller can fix, but it is not a frame either. + auto blank = client.Get("/vehicles/2/camera.jpg"); + CHECK(blank && blank->status == 404); + CHECK(blank && blank->body.find("no frame") != std::string::npos); + + // Addressed by ArduPilot instance, resolved through the vehicle list: + // instance 30 is vehicle id 1 above. + auto by_instance = client.Get("/instances/30/camera.jpg"); + CHECK(by_instance && by_instance->status == 200); + CHECK(by_instance && by_instance->body.size() == canned.size()); + + auto unknown = client.Get("/instances/99/camera.jpg"); + CHECK(unknown && unknown->status == 404); + CHECK(unknown && unknown->body.find("no such instance") != std::string::npos); + + // The MJPEG stream never ends, so take the first frame and hang up. What + // matters is that it is multipart and that a frame arrives inside it. + for (const char *path : {"/vehicles/1/camera.mjpg", "/instances/30/camera.mjpg"}) { + std::string got; + client.Get(path, [&got](const char *data, size_t len) { + got.append(data, len); + return got.find("\r\n\r\n") == std::string::npos; // stop after one frame's header + }); + CHECK(got.find("--skysimframe") != std::string::npos); + CHECK(got.find("Content-Type: image/jpeg") != std::string::npos); + } + } + // Rebind after teardown must work (the port is released, not leaked). Note a LIVE // conflict cannot be tested here: httplib sets SO_REUSEPORT, so two servers on one // port both bind and the kernel load-balances — don't share --api-port between sims. diff --git a/tests/test_render.cpp b/tests/test_render.cpp index 147357a..a38f831 100644 --- a/tests/test_render.cpp +++ b/tests/test_render.cpp @@ -10,10 +10,13 @@ #include #include #include +#include #include "core/world.h" +#include "render/frame_store.h" #include "render/jpeg.h" #include "render/raster.h" +#include "render/render_service.h" #include "render/renderer.h" #include "terrain/cook.h" @@ -203,6 +206,67 @@ int main() { CHECK(threaded.rgb == r.rgb); } + // --- FrameStore hands one frame per vehicle across threads. --- + { + skysim::render::FrameStore store; + CHECK(store.get(1).jpeg.empty()); // never rendered + CHECK(store.get(1).sim_time_s == 0.0); + + store.publish(1, {1, 2, 3}, 4.5); + CHECK(store.get(1).jpeg.size() == 3); + CHECK(store.get(1).sim_time_s == 4.5); + CHECK(store.get(2).jpeg.empty()); // one vehicle's frame is not another's + + // Replaced whole, not appended to: a viewer wants the newest picture. + store.publish(1, {9}, 5.0); + CHECK(store.get(1).jpeg.size() == 1); + CHECK(store.get(1).jpeg[0] == 9); + + store.erase(1); + CHECK(store.get(1).jpeg.empty()); + store.erase(404); // erasing a vehicle that was never there is not an error + } + + // --- RenderService draws on its own thread, off its own world. --- + // + // The service is what actually runs in flight, and its two failure modes are + // both silent: a world that loaded no geometry, and a thread that never + // produces a frame. Both look like a working server serving nothing. + { + const auto tiles = std::filesystem::temp_directory_path() / "skysim_service_tiles"; + std::filesystem::create_directories(tiles); + write_test_obj(tiles / "building.obj"); + CHECK(skysim::terrain::cook_obj_tile(tiles / "building.obj", tiles / "building.jshape", nullptr, &err)); + + skysim::render::RenderService::Config scfg; + scfg.camera = cam; + scfg.tiles_dir = tiles.string(); + scfg.fps = 60.0; // fast, so the test is not mostly sleeping + scfg.threads = 2; + + skysim::render::RenderService service(scfg); + CHECK(service.tiles_loaded() == 1); + CHECK(service.triangle_count() > 0); + CHECK(service.frame(1).empty()); // nothing published yet, so nothing to draw + + skysim::render::RenderService::Pose pose; + pose.vehicle_id = 1; + pose.position_ned = pos; + pose.quat_ned_frd = level; + service.publish_poses({pose}); + + std::vector frame; + for (int i = 0; i < 200 && frame.empty(); ++i) { + std::this_thread::sleep_for(std::chrono::milliseconds(10)); + frame = service.frame(1); + } + CHECK(!frame.empty()); + CHECK(frame.size() > 2 && frame[0] == 0xFF && frame[1] == 0xD8); // a real JPEG + CHECK(service.frame(2).empty()); // only the posed vehicle + + std::filesystem::remove_all(tiles); + } // ~RenderService must stop and join its thread rather than hang here + std::filesystem::remove_all(dir); if (g_failures == 0) { std::printf("test_render: OK\n"); From 47de35f8d5fbd15f71995ed4c6e089bff262f0ba Mon Sep 17 00:00:00 2001 From: yallex Date: Sun, 9 Aug 2026 08:06:23 +0300 Subject: [PATCH 3/4] Address review: two real drawing bugs, an unhandled throw, and a frame leak MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CodeRabbit found more than nits. In order of how much they mattered: - The ray caster's synthesized ground hit left is_ground false, so every pixel taking that fallback was painted building grey on the ground plane. I introduced this in the same change that stopped guessing from the normal — the fallback constructs its own RayHit and I did not update it. - The two camera routes parsed vehicle ids with std::stoul, which throws on anything wider than unsigned long. /vehicles/99999999999999999999/camera.jpg was an uncaught exception; every other handler in the file already used strtoul, which saturates. - Frames of despawned vehicles were never dropped, so the camera route kept serving a picture of an aircraft that no longer existed and FrameStore grew for the life of the process. The pose list is already the whole fleet, so it is the natural owner of "which vehicles exist": anything absent is retained no longer. sim_time_s is carried through from the tick rather than stored as 0, so a consumer can tell a fresh frame from a stalled one. - The rasteriser hazed against camera-space depth while the ray caster used radial distance — about 25% apart at the edge of a 78 degree frame, on a pair of renderers whose shared header says they must draw the same picture. Both now work in squared distance, which also removes a division: 0.67 -> 0.58 ms/frame. - haze_at divided by max_range_m without checking it, and the NaN survived all the way to a cast that is undefined behaviour. - --camera-threads, --camera-quality and --frame-grace were unvalidated, and each fails silently rather than loudly: an undefined pool size, stb reading quality 0 as 90, and a grace above 1.0 stretching every tick past its period. - render_probe reported success when it could not open or write its output. - The getting-started probes could hang forever against a server that accepts the connection and says nothing. Skipped: bounding what the render service loads by radius or tile cap. It is a fair point — the physics world caps resident tiles and this one does not — but the Jolt shapes are already released after extraction, leaving only the triangle list, and doing it properly means streaming against the fleet's position. Worth its own change, not this one. Coverage stays over the gates: 86.4% lines, 96.0% functions, 72.7% branches. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_017cgM68QE3FDz7S2pQAfTaZ --- .github/assets/skysim-getting-started.jpg | Bin 42247 -> 42754 bytes README.md | 13 +++++----- src/api/control_server.cpp | 12 ++++----- src/core/world.h | 6 +++-- src/main.cpp | 19 +++++++++++++- src/render/frame_store.h | 15 +++++++++++ src/render/raster.cpp | 25 ++++++++++++++++++- src/render/render_service.cpp | 12 ++++++++- src/render/render_service.h | 3 +++ src/render/renderer.cpp | 1 + src/render/shading.h | 16 ++++++++++-- tests/test_render.cpp | 29 ++++++++++++++++++++-- tools/getting_started.sh | 8 ++++-- tools/render_probe/main.cpp | 9 +++++++ 14 files changed, 145 insertions(+), 23 deletions(-) diff --git a/.github/assets/skysim-getting-started.jpg b/.github/assets/skysim-getting-started.jpg index 2740faea79f1521f850ff8b4d32830ed2f602241..f26fe849f691716bfa7508515ae0a5d3b238719e 100644 GIT binary patch literal 42754 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

cS6 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 NKac#!;^AK-{|5;|5O4qh diff --git a/README.md b/README.md index 8df2ed4..4acc4d5 100644 --- a/README.md +++ b/README.md @@ -86,9 +86,9 @@ Read in depth: [`docs/PROTOCOL.md`](docs/PROTOCOL.md) → [`docs/DESIGN.md`](doc ## Getting started -One command, and the only prerequisites are the build tools. It builds skysim, cooks a -1.2 km demo city, and renders it from four camera poses — the same rasteriser that feeds -a vehicle's camera stream in flight, so what you get is what a drone would see: +One command. It builds skysim, cooks a 1.2 km demo city, and renders it from four camera +poses — the same rasteriser that feeds a vehicle's camera stream in flight, so what you +get is what a drone would see: ```bash tools/getting_started.sh @@ -96,9 +96,10 @@ tools/getting_started.sh ![Four views of the demo city rendered by skysim](.github/assets/skysim-getting-started.jpg) -Images land in `build/getting_started/`. Nothing above needs ArduPilot, a network or a -running simulator — it is there so you can see the thing work before committing to the -half hour the autopilot build takes. +Images land in `build/getting_started/`. Needs the build tools, Python 3, and — on a +clean checkout — network access, because CMake fetches JoltPhysics, cpp-httplib and stb. +It does **not** need ArduPilot or a running simulator, which is the point: you can see +the thing work before committing to the half hour the autopilot build takes. Once you do have ArduPilot (see below), the same script will fly a vehicle through that city and serve its camera live: diff --git a/src/api/control_server.cpp b/src/api/control_server.cpp index ea6d5e6..2d3050a 100644 --- a/src/api/control_server.cpp +++ b/src/api/control_server.cpp @@ -16,13 +16,10 @@ namespace skysim::api { namespace { + // Two long-lived camera streams per vehicle, plus headroom for the control // plane, which must stay answerable while they run. constexpr size_t kWorkerThreads = 64; -} // namespace - - -namespace { // {"launch_process":true} — absent key means false. bool parse_launch_process(const std::string &body) { @@ -354,8 +351,11 @@ ControlServer::ControlServer(const std::string &bind_addr, int port, CommandQueu res.set_content("{\"error\":\"camera disabled\"}", "application/json"); return; } + // strtoul, not stoul: the route regex accepts any number of digits, and + // stoul throws on a value too big for the type. Saturating is the same + // answer as "no such vehicle" without the exception. std::vector jpeg = snapshots.camera_frame( - static_cast(std::stoul(req.matches[1].str()))); + static_cast(std::strtoul(req.matches[1].str().c_str(), nullptr, 10))); if (jpeg.empty()) { res.status = 404; res.set_content("{\"error\":\"no frame\"}", "application/json"); @@ -375,7 +375,7 @@ ControlServer::ControlServer(const std::string &bind_addr, int port, CommandQueu res.set_content("{\"error\":\"camera disabled\"}", "application/json"); return; } - const auto id = static_cast(std::stoul(req.matches[1].str())); + const auto id = static_cast(std::strtoul(req.matches[1].str().c_str(), nullptr, 10)); res.set_chunked_content_provider( "multipart/x-mixed-replace; boundary=skysimframe", [snapshots, id](size_t /*offset*/, httplib::DataSink &sink) { diff --git a/src/core/world.h b/src/core/world.h index 1e810a8..375c9b2 100644 --- a/src/core/world.h +++ b/src/core/world.h @@ -111,8 +111,10 @@ class World { struct RayHit { bool hit{false}; double distance{-1.0}; - Vec3 normal_ned{0.0, 0.0, -1.0}; // unit, points back towards the ray - bool is_ground{false}; // the ground slab, as opposed to a building + // Unit outward surface normal of the body that was hit. Not oriented against + // the ray: a hit on a back face returns a normal pointing away from the eye. + Vec3 normal_ned{0.0, 0.0, -1.0}; + bool is_ground{false}; // the ground slab, as opposed to a building }; RayHit raycast_surface(const Vec3 &origin_ned, const Vec3 &dir_ned, double max_dist_m, diff --git a/src/main.cpp b/src/main.cpp index b486a2e..0f53d7c 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -187,6 +187,22 @@ Options parse_args(int argc, char **argv) { std::fprintf(stderr, "skysim: invalid options (--time-mode strict|interactive)\n"); std::exit(2); } + // These three change timing or thread counts rather than failing outright, so + // an out-of-range value does not announce itself: a negative thread count is + // an undefined pool size, a grace above 1 stretches every tick past its + // period, and stb quietly reads quality 0 as 90. + if (o.camera_threads < 1) { + std::fprintf(stderr, "skysim: --camera-threads must be at least 1\n"); + std::exit(2); + } + if (o.camera_quality < 1 || o.camera_quality > 100) { + std::fprintf(stderr, "skysim: --camera-quality must be 1-100\n"); + std::exit(2); + } + if (o.frame_grace < 0.0 || o.frame_grace > 1.0) { + std::fprintf(stderr, "skysim: --frame-grace must be 0.0-1.0 (a fraction of the tick period)\n"); + std::exit(2); + } return o; } @@ -705,10 +721,11 @@ struct App { if (!render_service) { return; } + const double now = world ? world->now() : 0.0; std::vector poses; poses.reserve(fleet.size()); for (const auto &v : fleet) { - poses.push_back({v->vehicle_id, v->state.pos_ned, v->state.quat_ned_frd}); + poses.push_back({v->vehicle_id, v->state.pos_ned, v->state.quat_ned_frd, now}); } render_service->publish_poses(std::move(poses)); } diff --git a/src/render/frame_store.h b/src/render/frame_store.h index 1540fa8..310e2ec 100644 --- a/src/render/frame_store.h +++ b/src/render/frame_store.h @@ -1,6 +1,8 @@ #pragma once +#include #include +#include #include #include #include @@ -44,6 +46,19 @@ class FrameStore { frames_.erase(vehicle_id); } + // Drop every vehicle not in `keep`. + // + // Without this a despawned vehicle's last frame outlives it: the camera route + // keeps serving a picture for an aircraft that no longer exists, instead of + // the 404 the caller needs to see, and the map grows for the life of the + // process as ids churn. + void retain(const std::vector &keep) { + std::lock_guard lock(mutex_); + for (auto it = frames_.begin(); it != frames_.end();) { + it = std::find(keep.begin(), keep.end(), it->first) == keep.end() ? frames_.erase(it) : std::next(it); + } + } + private: mutable std::mutex mutex_; std::unordered_map frames_; diff --git a/src/render/raster.cpp b/src/render/raster.cpp index 9082f88..c6b811a 100644 --- a/src/render/raster.cpp +++ b/src/render/raster.cpp @@ -190,6 +190,19 @@ void Rasterizer::draw_background(const Camera &camera, Image &image, int y0, int void Rasterizer::fill_band(const std::vector &tris, Image &image, std::vector &depth, int y0, int y1) const { + // Per-axis tangent offsets, so a pixel's camera-space depth can be turned + // into its radial distance with two adds and a multiply. Squared, because + // the only consumer squares them again. + const double tan_x = std::tan((cfg_.fov_deg * M_PI / 180.0) * 0.5); + const double tan_y = tan_x * cfg_.height / cfg_.width; + const double max_range_sq = cfg_.max_range_m * cfg_.max_range_m; + + std::vector kx(static_cast(cfg_.width)); + for (int x = 0; x < cfg_.width; ++x) { + const double sx = ((x + 0.5) / cfg_.width) * 2.0 - 1.0; + kx[static_cast(x)] = (sx * tan_x) * (sx * tan_x); + } + for (const ScreenTriangle &t : tris) { if (t.max_y < y0 || t.min_y >= y1) { continue; // not in this band @@ -208,6 +221,8 @@ void Rasterizer::fill_band(const std::vector &tris, Image &image for (int y = ys; y <= ye; ++y) { const float py = y + 0.5f; + const double sy = 1.0 - ((y + 0.5) / cfg_.height) * 2.0; + const double ky_row = (sy * tan_y) * (sy * tan_y); for (int x = xs; x <= xe; ++x) { const float px = x + 0.5f; @@ -236,7 +251,15 @@ void Rasterizer::fill_band(const std::vector &tris, Image &image // Haze grows with distance but never fully erases a surface: // capped, so the far edge of the draw distance reads as far away // rather than as blank sky. - const double haze = haze_at(z, cfg_.max_range_m); + // + // Against radial distance, not z. z is depth along the camera's + // forward axis, and at the edge of a 78 degree frame that is + // ~25% short of how far the surface actually is — enough that the + // same wall hazed differently here than in the ray caster, which + // shading.h says must not happen. Squared throughout, so the + // conversion costs a multiply instead of a square root per pixel. + const double radial_sq = static_cast(z) * z * (1.0 + kx[x] + ky_row); + const double haze = haze_at_sq(radial_sq, max_range_sq); const size_t pi = (static_cast(y) * cfg_.width + x) * 3; for (int c = 0; c < 3; ++c) { diff --git a/src/render/render_service.cpp b/src/render/render_service.cpp index bb49d5a..45076a1 100644 --- a/src/render/render_service.cpp +++ b/src/render/render_service.cpp @@ -60,6 +60,16 @@ void RenderService::run() { poses = poses_; } + // The pose list is the whole fleet, so anything not in it has despawned. + // Dropping their frames here rather than hooking despawn keeps one owner + // of the question "which vehicles exist" — this list, once a tick. + std::vector live; + live.reserve(poses.size()); + for (const Pose &pose : poses) { + live.push_back(pose.vehicle_id); + } + frames_.retain(live); + for (const Pose &pose : poses) { if (stop_.load(std::memory_order_relaxed)) { break; @@ -68,7 +78,7 @@ void RenderService::run() { const Image image = rasterizer_->render(scene_, camera, pool_.get()); auto jpeg = encode_jpeg(image, cfg_.quality); if (!jpeg.empty()) { - frames_.publish(pose.vehicle_id, std::move(jpeg), 0.0); + frames_.publish(pose.vehicle_id, std::move(jpeg), pose.sim_time_s); } } diff --git a/src/render/render_service.h b/src/render/render_service.h index 5f6f32e..56e8668 100644 --- a/src/render/render_service.h +++ b/src/render/render_service.h @@ -47,6 +47,9 @@ class RenderService { uint32_t vehicle_id{0}; core::Vec3 position_ned{}; core::Quat quat_ned_frd{1.0, 0.0, 0.0, 0.0}; + // When the tick that produced this pose happened, carried through to the + // frame so a consumer can tell a fresh picture from a stalled one. + double sim_time_s{0.0}; }; explicit RenderService(const Config &cfg); diff --git a/src/render/renderer.cpp b/src/render/renderer.cpp index c7a70a0..56fa70a 100644 --- a/src/render/renderer.cpp +++ b/src/render/renderer.cpp @@ -64,6 +64,7 @@ void Renderer::render_rows(const core::World &world, const Camera &camera, uint3 hit.hit = true; hit.distance = ground_dist; hit.normal_ned = {0.0, 0.0, -1.0}; // ground faces up; NED up is -z + hit.is_ground = true; // it is the ground, so say so, or it draws grey } double colour[3]; diff --git a/src/render/shading.h b/src/render/shading.h index 2621fc4..f4441f2 100644 --- a/src/render/shading.h +++ b/src/render/shading.h @@ -57,9 +57,21 @@ inline void sky_colour(const core::Vec3 &dir, double out[3]) { } //: How much haze sits between the camera and something this far away. +//: Squared form, for callers that have a squared distance already. The rasteriser +//: does: turning its camera-space depth into a true radial distance needs a square +//: root per pixel, and haze only ever squares the ratio again. +inline double haze_at_sq(double distance_sq, double max_range_sq) { + // A non-positive range would divide by zero, and the NaN survives clamp and + // min all the way to a cast that is undefined behaviour. Nothing is visible + // at zero range anyway, so everything is as hazy as it gets. + if (!(max_range_sq > 0.0)) { + return kMaxHaze; + } + return std::min(std::clamp(distance_sq / max_range_sq, 0.0, 1.0), kMaxHaze); +} + inline double haze_at(double distance_m, double max_range_m) { - const double fog = std::clamp(distance_m / max_range_m, 0.0, 1.0); - return std::min(fog * fog, kMaxHaze); + return haze_at_sq(distance_m * distance_m, max_range_m * max_range_m); } } // namespace skysim::render diff --git a/tests/test_render.cpp b/tests/test_render.cpp index a38f831..a4efaae 100644 --- a/tests/test_render.cpp +++ b/tests/test_render.cpp @@ -170,8 +170,12 @@ int main() { const std::vector jpeg = skysim::render::encode_jpeg(img, 70); CHECK(jpeg.size() > 256); CHECK(jpeg.size() < img.rgb.size()); // compression actually happened - CHECK(jpeg[0] == 0xFF && jpeg[1] == 0xD8); // SOI - CHECK(jpeg[jpeg.size() - 2] == 0xFF && jpeg[jpeg.size() - 1] == 0xD9); // EOI + // Guarded: CHECK keeps going after a failure, and indexing an empty buffer + // would crash the run instead of reporting which assertion failed. + if (jpeg.size() >= 4) { + CHECK(jpeg[0] == 0xFF && jpeg[1] == 0xD8); // SOI + CHECK(jpeg[jpeg.size() - 2] == 0xFF && jpeg[jpeg.size() - 1] == 0xD9); // EOI + } // --- An empty image encodes to nothing rather than crashing. --- CHECK(skysim::render::encode_jpeg(skysim::render::Image{}, 70).empty()); @@ -225,6 +229,16 @@ int main() { store.erase(1); CHECK(store.get(1).jpeg.empty()); store.erase(404); // erasing a vehicle that was never there is not an error + + // A despawned vehicle's last frame must go with it, or the camera route + // serves a picture of an aircraft that no longer exists. + store.publish(1, {1}, 1.0); + store.publish(2, {2}, 1.0); + store.retain({2}); + CHECK(store.get(1).jpeg.empty()); + CHECK(!store.get(2).jpeg.empty()); + store.retain({}); // whole fleet gone + CHECK(store.get(2).jpeg.empty()); } // --- RenderService draws on its own thread, off its own world. --- @@ -253,6 +267,7 @@ int main() { pose.vehicle_id = 1; pose.position_ned = pos; pose.quat_ned_frd = level; + pose.sim_time_s = 12.5; service.publish_poses({pose}); std::vector frame; @@ -264,6 +279,16 @@ int main() { CHECK(frame.size() > 2 && frame[0] == 0xFF && frame[1] == 0xD8); // a real JPEG CHECK(service.frame(2).empty()); // only the posed vehicle + // Despawn it: the next pass must drop the frame rather than keep serving + // a picture of a vehicle that is gone. + service.publish_poses({}); + bool cleared = false; + for (int i = 0; i < 200 && !cleared; ++i) { + std::this_thread::sleep_for(std::chrono::milliseconds(10)); + cleared = service.frame(1).empty(); + } + CHECK(cleared); + std::filesystem::remove_all(tiles); } // ~RenderService must stop and join its thread rather than hang here diff --git a/tools/getting_started.sh b/tools/getting_started.sh index fc17390..271d4af 100755 --- a/tools/getting_started.sh +++ b/tools/getting_started.sh @@ -112,8 +112,12 @@ if [ "$FLY" = "1" ]; then exit 1 } - api_up() { curl -sf "http://127.0.0.1:$API_PORT/vehicles" >/dev/null; } - vehicle_connected() { curl -sf "http://127.0.0.1:$API_PORT/vehicles" | grep -q '"connected":true'; } + # Deadlines on both: a server that accepts the connection and then says + # nothing would hang curl forever, and wait_for would never get to retry or + # to notice the process had died. + probe() { curl -sf --connect-timeout 1 --max-time 2 "http://127.0.0.1:$API_PORT/vehicles"; } + api_up() { probe >/dev/null; } + vehicle_connected() { probe | grep -q '"connected":true'; } wait_for 100 $SKYSIM_PID \ "skysim never came up. If a port is taken: INSTANCE=1 API_PORT=8643 $0 --fly" api_up diff --git a/tools/render_probe/main.cpp b/tools/render_probe/main.cpp index af15da2..f17d89d 100644 --- a/tools/render_probe/main.cpp +++ b/tools/render_probe/main.cpp @@ -150,7 +150,16 @@ int main(int argc, char **argv) { } std::ofstream f(o.out, std::ios::binary); + if (!f) { + std::fprintf(stderr, "render_probe: cannot open %s\n", o.out.c_str()); + return 1; + } f.write(reinterpret_cast(jpeg.data()), static_cast(jpeg.size())); + f.close(); + if (!f) { + std::fprintf(stderr, "render_probe: failed writing %s\n", o.out.c_str()); + return 1; + } std::printf("render_probe: wrote %s (%zu bytes, %dx%d)\n", o.out.c_str(), jpeg.size(), image.width, image.height); return 0; } From edda4fb1c326f95575d17a260f5f78eaf08b5955 Mon Sep 17 00:00:00 2001 From: yallex Date: Sun, 9 Aug 2026 09:16:57 +0300 Subject: [PATCH 4/4] Make the MJPEG test prove a frame arrived, not just its header The stream callback stopped at the blank line after the multipart header, so the test would have passed against a server that announces a JPEG and then sends none of it. It reads until the frame's actual bytes appear and asserts them. Also: three requests in the camera-off block were dereferenced inline, which crashes the run rather than failing it when a request never reaches the server; frame_store.h uses std::move without including ; and the canned payload now carries the k prefix the rest of the file uses. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_017cgM68QE3FDz7S2pQAfTaZ --- src/render/frame_store.h | 1 + tests/test_api.cpp | 30 ++++++++++++++++++++---------- 2 files changed, 21 insertions(+), 10 deletions(-) diff --git a/src/render/frame_store.h b/src/render/frame_store.h index 310e2ec..de8c0e4 100644 --- a/src/render/frame_store.h +++ b/src/render/frame_store.h @@ -5,6 +5,7 @@ #include #include #include +#include #include namespace skysim::render { diff --git a/tests/test_api.cpp b/tests/test_api.cpp index ccbbcc3..67bda21 100644 --- a/tests/test_api.cpp +++ b/tests/test_api.cpp @@ -250,19 +250,24 @@ int main() { auto off = client.Get("/vehicles/1/camera.jpg"); CHECK(off && off->status == 404); CHECK(off && off->body.find("camera disabled") != std::string::npos); - CHECK(client.Get("/vehicles/1/camera.mjpg")->status == 404); - CHECK(client.Get("/instances/30/camera.jpg")->status == 404); - CHECK(client.Get("/instances/30/camera.mjpg")->status == 404); + // Held and checked rather than dereferenced inline: a request that never + // reached the server yields no response at all, and reading through that + // crashes the test instead of failing it. + for (const char *path : {"/vehicles/1/camera.mjpg", "/instances/30/camera.jpg", + "/instances/30/camera.mjpg"}) { + auto res = client.Get(path); + CHECK(res && res->status == 404); + } } // --- Camera endpoints, camera on. --- { // Not a real JPEG: these routes move bytes and pick status codes, and // whether those bytes decode is test_render's business. - const std::vector canned{0xFF, 0xD8, 'p', 'i', 'x', 0xFF, 0xD9}; + const std::vector kCanned{0xFF, 0xD8, 'p', 'i', 'x', 0xFF, 0xD9}; ControlServer::Snapshots with_camera = snaps; - with_camera.camera_frame = [canned](uint32_t id) { - return id == 1 ? canned : std::vector{}; + with_camera.camera_frame = [kCanned](uint32_t id) { + return id == 1 ? kCanned : std::vector{}; }; ControlServer server("127.0.0.1", kCameraPort, queue, with_camera); @@ -272,7 +277,7 @@ int main() { auto shot = client.Get("/vehicles/1/camera.jpg"); CHECK(shot && shot->status == 200); CHECK(shot && shot->get_header_value("Content-Type") == "image/jpeg"); - CHECK(shot && shot->body.size() == canned.size()); + CHECK(shot && shot->body.size() == kCanned.size()); // A vehicle that exists but has not been drawn yet is not an error the // caller can fix, but it is not a frame either. @@ -284,7 +289,7 @@ int main() { // instance 30 is vehicle id 1 above. auto by_instance = client.Get("/instances/30/camera.jpg"); CHECK(by_instance && by_instance->status == 200); - CHECK(by_instance && by_instance->body.size() == canned.size()); + CHECK(by_instance && by_instance->body.size() == kCanned.size()); auto unknown = client.Get("/instances/99/camera.jpg"); CHECK(unknown && unknown->status == 404); @@ -292,14 +297,19 @@ int main() { // The MJPEG stream never ends, so take the first frame and hang up. What // matters is that it is multipart and that a frame arrives inside it. + const std::string payload(reinterpret_cast(kCanned.data()), kCanned.size()); for (const char *path : {"/vehicles/1/camera.mjpg", "/instances/30/camera.mjpg"}) { + // Read until the frame's bytes actually arrive, not just its header: + // stopping at the blank line would pass on a stream that announces a + // JPEG and then sends nothing. std::string got; - client.Get(path, [&got](const char *data, size_t len) { + client.Get(path, [&got, &payload](const char *data, size_t len) { got.append(data, len); - return got.find("\r\n\r\n") == std::string::npos; // stop after one frame's header + return got.find(payload) == std::string::npos; }); CHECK(got.find("--skysimframe") != std::string::npos); CHECK(got.find("Content-Type: image/jpeg") != std::string::npos); + CHECK(got.find(payload) != std::string::npos); } }