電波は、アンテナからちぎれた電場の輪になって飛んでいく

Wi-Fiのアンテナの中では、電流が1秒に24億回往復している。そのそばで生まれた電気力線は半周期ごとに輪になってちぎれ、光の速さでアンテナを離れていく。

記事

電波は、アンテナからちぎれた電場の輪になって飛んでいく

Wi-Fiのアンテナの中では、電流が1秒に24億回往復している。そのそばで生まれた電気力線は半周期ごとに輪になってちぎれ、光の速さでアンテナを離れていく。

作成日 2026年11月4日・更新日 2026年11月4日・執筆・監修 佐々木 航

現象

1秒に24億回の往復が、電波を送り出す

家のWi-Fiルーターでは、アンテナの中を電流が1秒に約24億回往復している。周波数2.4 GHzの電波だ。2,400〜2,483.5 MHzのこの帯域なら、日本では免許なしに無線LANを使える1。電波は光と同じ速さで進む。だから、電流が1回往復するあいだに約12.5 cm先まで届く。この長さが波長である。

では、アンテナから飛んでいくものは何なのか。空気が揺れるわけではない。真空の宇宙でも電波は届く。進んでいくのは、電場と磁場の形そのものだ。

画面の真ん中にある短い棒が、アンテナだ。点線の円で囲んだ縦の面には、電気力線が引いてある。プラスの電気を置いたら押される向きを、つないでいった線だ。アンテナのすぐそばでは、線が棒の上端から出て下端へ戻る。ところが半周期ごとに外側の線にくびれができ、閉じた輪になってちぎれる。ちぎれた輪は、もう棒には戻らない。

奥にある水平な半円は、アンテナを真横に切った面。面の高さは縦向きの電場を、横に並んだ⊙と⊗は磁場の向きを表す。⊙は画面の手前へ、⊗は奥へ向かう印だ。観測点(点線の小さな輪)をドラッグすれば、その場所の電場と磁場を測れる。

仕組み

変わる磁場が電場を、変わる電場が磁場を生む

電波が進み続けられるのは、電場と磁場が互いを生み直すからだ。コイルの中で磁石を動かすと電流が流れるように、磁場が変わると、そのまわりに電場ができる。逆に電場が変わると、そのまわりに磁場が生まれる。この2つの法則を含む4つの式が、マクスウェル方程式である。変化は隣へ隣へと受け渡され、その速さは電気と磁気の2つの定数だけで決まる。

は真空の誘電率(F/m)、は真空の透磁率(N/A²)だ。米国国立標準技術研究所(NIST)が公表する値で計算すると、は秒速29万9,792 kmと光の速さにぴたりと重なった2。

アンテナの真ん中から電流を送り込むと、上端にプラス、下端にマイナスの電気がたまり、半周期後には逆になる。向きを変え続けるこの電気の組が、振動する電気双極子だ。まわりにできる電場は、ヘルツが1889年に式で求めた3。アンテナの軸から測った角度を、距離を(m)とすると、アンテナから見た向きに直角な電場の成分(V/m)は次の形になる。

は、たまる電気の量に両端の間隔を掛けた値の最大値(C·m)。波長を、周波数をとすると、は波数、は角振動数だ。が付いた項はほかの項と4分の1周期ずれて変わることを示し、本物の電場は右辺の実部にあたる。

近くと遠くの違いを決めるのは、かっこの中にある3つの項だ。の項は両端にたまった電気がその場で作る電場で、止まった電気が作る電場と同じ形である。の項は誘導電磁界、の項は放射電磁界と呼ばれ、遠くまで届くのはの項だけ。3つの大きさがそろうのはの所で、2.4 GHzならアンテナから約2 cmになる。

1/r³の項(両端の電荷が作る) 1/r²の項(誘導電磁界) 1/rの項(放射電磁界) 0.01λ 0.1λ 1λ アンテナからの距離r(目盛りは10倍ごと) 100 1 0.01 r = λ/2π (2.4 GHzで約2 cm) 項の大きさ(3つが等しい所を1)
アンテナの真横での電場の3つの項。距離λ/2πを境に、近くでは1/r³の項、遠くでは1/rの項が大きくなる

電場と磁場の関係も、近くと遠くでは同じではない。遠くでは電場と磁場が互いに直角で、進む向きとも直角になり、山と谷の時刻がそろう。電場を磁場で割った値は、光の速さに等しい。ところが近くでは、電場と磁場が山になる時刻が4分の1周期近くずれる。エネルギーの大半は、アンテナとそのまわりを行き来しているだけなのだ。

電気力線を描くには、次のが同じ値になる点をつなげばよい。

アンテナのそばではかっこの2項目が大きく、線の形は止まった電気の場合とほぼ同じだ。遠くでは1項目だけが残り、線は半波長ごとに向きを変えながら並んで、模様ごと光の速さで外へ動く。両端の電気が向きを変えると、近くの線は棒へ戻っていくが、遠くまで張り出した線は戻りきれない。その境目にくびれが生まれ、輪がちぎれるのだ。

向きによって、電波の強さはどう変わるのか。アンテナが出す電力を(W)とすると、1周期でならした電力の流れ(W/m²)は次の式で表せる。

真横()でいちばん強く、軸の向き()では0。向きごとの強さを中心からの距離に置きかえて立体にすると、ドーナツのような形が現れる。

仕組み

壁やすき間のある所は、格子の上で解く

壁やすき間、何本ものアンテナが入ると、答えを式で書き下せない。そこで空間を細かいますに分け、マクスウェル方程式に従って時間を少しずつ進める。1966年にK. S. Yeeが発表したこの方法が、FDTD法(時間領域差分法)である4。電場と磁場を半ますずらして置き、交互に計算し直していく。

平面の場面では、アンテナを縦に長い電流とみなし、縦向きの電場と水平な磁場、だけを計算している。は、磁場をで割った量(A/m)だ。壁は、電気のたまりやすさを表す誘電率と、電流の流れやすさを表す導電率(S/m)の違いとして入れる。

左辺は電場が変わる速さと壁を流れる電流で、右辺はまわりの磁場が渦を巻く強さ。

たとえば金属はがとても大きいので中の電場が0になり、来た電波をほとんどはね返す。コンクリートの値は、携帯電話の規格を作る3GPPの報告書に、国際電気通信連合(ITU)の数字として載っている5。2.4 GHzでは比誘電率5.31、導電率0.066 S/mだ。電波は中で2.3分の1の速さに落ち、進みながら熱に変わって弱まっていく。

強さの比は、dB(デシベル)で表すことが多い。電力が2分の1なら−3 dB、10分の1なら−10 dBだ。シミュレーションでは、厚さ15 cmのコンクリート壁が、正面から当たる電波の電力を約9 dB、つまり8分の1ほどに減らした。平面の波が板を通り抜ける式で計算した8.8 dBと、ほぼ同じである。一方、厚さ12.5 mmの石膏ボードは、1枚では2 dBも弱めなかった。

ますの幅と時間の刻みは、勝手には選べない。1刻みで電波が進む距離がますに比べて長すぎると、計算が発散してしまう。2次元ではが条件(クーラン条件)で、シミュレーションはとした。範囲の端には、来た電波を吸い込んではね返さない層を置いてある6。

そろって振動する2本のアンテナの波は、道のりの差が波長の整数倍になる所で強め合い、半波長ずれる所で打ち消し合う。では、何本も一直線に並べ、右のアンテナほど電流の位相(振動の進み具合)をずつ遅らせたらどうなるか。波の山がそろう向きが、正面から傾く。アンテナの間隔を、正面から測ったその向きをとすると、関係は次の式になる。

間隔d d sin θ θ 電波の向き 真正面 波の山がそろう線 位相0 −Δφ −2Δφ
となりのアンテナとの道のりの差d sin θが、位相の遅れΔφにあたる距離と等しくなる向きに、波の山がそろう

間隔が半波長なら、式は。アンテナを1本も動かさずに、電流の位相だけで電波の向きを変えられる。このように素子ごとの振幅と位相を変えて電波の向きを作ることを、ビームフォーミングと呼ぶ。

試す

輪がちぎれる所から、壁の向こうまで

  1. アンテナのそばで生まれた線がふくらみ、くびれてちぎれ、外へ進んでいく。19.9 cm(1.6波長)離れた観測点では、E ÷ Bが2.97 × 10⁸ m/sと光の速さにほぼ等しく、山のずれは0°。観測点をアンテナの真横1 cmまで近づけると、ずれは83°まで開く。近くの電場は主に両端にたまった電気が、磁場は棒を流れる電流が作っていて、電気がいちばんたまった瞬間に電流は0になるからだ。
  2. 奥のドーナツ形の立体は、向きごとの電力の流れを表す。軸から45°の向きにある観測点では、電力の流れが0.068 W/m²、真横との比が50%()になる。観測点を軸の真上へ動かすと、比は0%に近づく。電気は棒に沿って上下に動くだけなので、真上から見ると揺れが見えず、その向きには電波が出ない。
  3. 2.4 GHzのアンテナ2本を3波長(37.5 cm)離し、そろえて振動させる。電波が広がりきったら、「面の高さ」を「強さの平均」に切りかえる。強い筋と弱い谷が、扇のように交互に並ぶ。真ん中の受信機は1本のときより約6 dB強く、電場が2倍、電力が4倍になっている。受信機を13 cmほど横へずらすと、2本からの道のりの差がほぼ半波長になり、−15 dB前後の谷に落ちる。
  4. 5Gで使う3.7 GHzのアンテナ8本が、半波長(4.05 cm)おきに並ぶ。裏には金属の反射板がある。位相差60°では、電波は右へ20°あまり傾く(式では19.5°)。左寄りの受信機は、1本のときより8 dBほど強いだけだ。位相差を−60°にすると電波が受信機の方へ振れて、+23 dBまで上がる。8本ぶんの重ね合わせで約18 dB、反射板で約6 dBが上乗せされるからだ。
  5. 金属の板に、幅25 cm(2波長)のすき間がある。すき間を抜けた波は、そこから新しく出たかのように扇形に広がる。この回り込みを回折といい、アンテナを真っすぐ見通せない板の陰の受信機にも、壁がないときより12 dBほど弱い電波が届く。板の手前では、来た波とはね返った波が重なって縞ができる。「壁を描く」を入れて金属を選び、すき間をなぞってふさぐと、受信機の値は−60 dBを下回る。
  6. 広さ4.6 m × 3.0 mの部屋で、外壁は厚さ15 cmのコンクリート、居間と寝室のあいだは石膏ボード2枚の壁だ。30 nsほどで部屋じゅうに電波が行き渡り、寝室の受信機にも壁がないときと同じくらいの電波が届く。戸口から回り込む波と、コンクリートではね返った波が加わるからだ。寝室の中では9割の場所が−9〜+8 dBに収まるが、12 cm動かしただけで10 dB変わる所もある。外壁の外へ出すと、多くの場所で−5〜−15 dBまで下がる。
現実では

ヘルツの講義室から、5Gの基地局へ

ヘルツは1888年、物理学の講義室で、空気中を伝わる電気の波について波長をじかに測った7。長さ約15 mある講義室の端にある壁に、高さ4 m、幅2 mの亜鉛の板を張り、13 m離れた所に縦向きの送信器を置いた。受信器は半径35 cmの金属の輪で、すき間に飛ぶ火花の強さで波をとらえる。ヘルツはそれまでの実験で、壁へ近づくにつれて火花が強まり、壁のすぐそばでふっと消えることに気づいていた。来る波と壁ではね返った波が重なり、強め合う所と打ち消し合う所が並んでいたのだ。「板とすき間」の場面で板の手前にできた縞と、同じ現象である。

翌1889年の論文では、振動する電気双極子がまわりに作る電気力線を、4分の1周期ごとに4枚の図にした3。外側の線がくびれ、閉じた線がちぎれてひとりでに空間を進んでいく様子も、ヘルツは言葉で書き残している。ちぎれた分のエネルギーが空間への放射であり、外から補わなければ振動はやがて止まる、とも述べた。

それから130年あまりたった2019年4月10日、総務省は5G用の3.7 GHz帯、4.5 GHz帯、28 GHz帯を携帯電話会社4社に割り当てる開設計画を認定した8。ただ、周波数が高い電波ほど、伝わる途中で弱まりやすい。NTTドコモの技術誌は2016年、5Gでは100素子以上のアンテナで電波の向きを細かく変えて、この弱まりを補うと説明している9。20 GHz帯なら、素子の間隔を半波長の7.5 mmにすると約12 cm四方に256素子が並び、素子ごとに振幅と位相を変えて鋭いビームを作れるという。「向きを変える」の場面にある8本を、平面に何百も並べた形だ。

建物の外から中へ入るとき、電波はどれだけ弱まるのか。3GPPの報告書の式では、周波数をGHzで表したを使って、ふつうの複層ガラスが dB、コンクリートが dBになる5。2.4 GHzならガラスで約2.5 dB、コンクリートで約15 dBだが、28 GHzではコンクリートが117 dBに達する。

省いていること

このシミュレーションが省いていること

  • 平面の場面は2次元の計算で、アンテナも壁も上下に限りなく長いとみなしている。そのため電場は距離の平方根に反比例して弱まり(本物は距離に反比例)、床や天井でのはね返りもない。部屋の強弱の模様は、本物の部屋を水平に切った面の目安である
  • アンテナ1本の場面は、長さが波長よりずっと短いダイポールの式で計算した。長さが半波長ほどある実際のダイポールアンテナとは1波長より近くの場の形が少し違い、ドーナツ形もわずかに細くなる。平面の場面のアンテナは縦に長い電流で、どれも振幅を10 mAとした
  • 壁は1つの周波数での誘電率と導電率だけで表し、鉄筋や中の空洞は入れていない。厚さはますの数に丸め、ますより薄い石膏ボードは1ますに薄めて入れた。コンクリートの中では波長が縮んでますが粗くなるので、本物の材質と同じ波長と弱まり方になるように、入れる値を直している
  • ますは1波長に6〜10個(画質による)で、波の進む速さが向きによってわずかにずれ、干渉の縞の位置や強さが少し狂う。壁のない所でアンテナ1本の電波を厳密な答えと比べると、画質が高のときの違いは0.6 dB以内。画質が低いとずれが大きく、コンクリートの壁も約12 dBと強めに効く。端の吸収層も、わずかにはね返す
  • 面の高さは電場の強さを表し、強い所は縮めて描いた。電波が面を揺らしているわけではない
参考文献

参考文献

  1. Hertz, H.「Ueber electrodynamische Wellen im Luftraume und deren Reflexion」『Annalen der Physik』270(8A), 609-623, 1888. 同「Die Kräfte electrischer Schwingungen, behandelt nach der Maxwell'schen Theorie」『Annalen der Physik』272(1), 1-22, 1889. 論文集『Untersuchungen über die Ausbreitung der elektrischen Kraft』の翻刻. https://de.wikisource.org/wiki/Elektrische_Kraft_Hertz:133 https://de.wikisource.org/wiki/Elektrische_Kraft_Hertz:151
  2. Yee, K. S.「Numerical solution of initial boundary value problems involving Maxwell's equations in isotropic media」『IEEE Transactions on Antennas and Propagation』14(3), 302-307, 1966.
  3. Berenger, J.-P.「A perfectly matched layer for the absorption of electromagnetic waves」『Journal of Computational Physics』114(2), 185-200, 1994.
  4. ETSI「5G; Study on channel model for frequencies from 0.5 to 100 GHz」ETSI TR 138 901 V17.0.0(3GPP TR 38.901と同じ内容), 2022. https://www.etsi.org/deliver/etsi_tr/138900_138999/138901/17.00.00_60/tr_138901v170000p.pdf
  5. 須山聡・奥山達樹・井上祐樹・岸山祥久「5Gマルチアンテナ技術」『NTT DOCOMOテクニカル・ジャーナル』23(4), 30-39, 2016. https://www.docomo.ne.jp/binary/pdf/corporate/technology/rd/technical_journal/bn/vol23_4/vol23_4_005jp.pdf
  6. 総務省『令和2年版 情報通信白書』第1部第1章第2節「特定基地局の開設計画の認定」2020. https://www.soumu.go.jp/johotsusintokei/whitepaper/ja/r02/html/nd112240.html
  7. 電波産業会「標準規格概要(STD-T66)第二世代小電力データ通信システム/ワイヤレスLANシステム」. https://www.arib.or.jp/kikaku/kikaku_tushin/desc/std-t66.html
  8. National Institute of Standards and Technology「CODATA Value: vacuum electric permittivity」「vacuum magnetic permeability」「speed of light in vacuum」2024. https://physics.nist.gov/cgi-bin/cuu/Value?ep0 https://physics.nist.gov/cgi-bin/cuu/Value?mu0 https://physics.nist.gov/cgi-bin/cuu/Value?c

注

  1. 参考文献7。電波法施行規則第6条第4項第4号の小電力データ通信システムで、免許の要らない無線局にあたる。 ↩

  2. 参考文献8。 F/m、 N/A²(2022年のCODATA推奨値)。光の速さ299,792,458 m/sは、SIで決めた値である。 ↩

  3. 参考文献1の1889年の論文(論文集の151〜158ページ)。図は論文集の図27〜30。 ↩ ↩2

  4. 参考文献2。 ↩

  5. 参考文献4の表7.6.8-1(もとはITU-R P.2040-1)と表7.4.3-1。コンクリートの導電率は S/m(はGHz)。 ↩ ↩2

  6. 参考文献3。完全整合層(PML)と呼ばれる。 ↩

  7. 参考文献1の1888年の論文(論文集の133〜135ページ)。 ↩

  8. 参考文献6。 ↩

  9. 参考文献5。 ↩

プログラム
プログラム

このシミュレーションのプログラム

このシミュレーションは、2種類の計算でできている。1本のアンテナの場面では、短いダイポールの電場と磁場を式からそのまま求め、縦の面に電気力線を引く。ほかの場面では、水平な面を細かいますに分け、マクスウェル方程式をますごとに少しずつ進める(FDTD法)。立体の描画と影と視点はthree.jsと共通の部品が受け持ち、このファイルのコードは物理の計算と、画面に重ねる線や文字を受け持つ。

SIの単位で、1 = 1 m

光の速さ、真空の誘電率と透磁率は、NISTの値をそのまま使う。長さはm、時間は秒で計算し、描く世界も1 = 1 mである。CPLは1波長に入れるますの数(画質ごとに6、8、10)、S_Cはクーラン数だ。MATSのa、c、dは、比誘電率と導電率(はGHz)の係数で、ETSI TR 138 901の表から写した。

定数と単位(src/sims/radio-waves.ts の 19〜38 行)
export const C0 = 299792458; // 光の速さ m/sexport const EPS0 = 8.8541878188e-12; // 真空の誘電率 F/mexport const MU0 = 1.25663706127e-6; // 真空の透磁率 N/A²const F_DIP = 2.4e9; // 式の場面の周波数 Hz(Wi-Fi の 2.4 GHz 帯)const P_DIP = 0.1; // 式の場面のアンテナが出す電力 Wexport const I_ANT = 0.01; // 平面の場面のアンテナに流す電流の振幅 Aconst CPL = [6, 8, 10]; // 1 波長に入れる格子のます(画質 0・1・2)export const S_C = 0.5; // クーラン数 cΔt/Δx。2 次元では 1/√2 以下なら計算が発散しないexport const NPML = 10; // 端の吸収層(PML)の厚さ(ます)const MAXSTEP = [4, 6, 8]; // 1 コマに進める刻みの上限(画質ごと)const MAXANT = 12; // アンテナの数の上限/** 壁の材質。比誘電率 ε = a、導電率 σ = c·f^d(f は GHz・σ は S/m)。t = 描く厚さ(m)・tone = 描く濃さ */export interface MatDef { label: string; a: number; c: number; d: number; t: number; tone: number; pec?: boolean }export const MATS: Record<string, MatDef> = {  metal: { label: "金属", a: 1, c: 1e7, d: 0, t: 0.002, tone: 0.3, pec: true },  concrete: { label: "コンクリート", a: 5.31, c: 0.0326, d: 0.8095, t: 0.15, tone: 0.6 },  board: { label: "石膏ボード", a: 2.94, c: 0.0116, d: 0.7076, t: 0.0125, tone: 0.95 },  wood: { label: "木", a: 1.99, c: 0.0047, d: 1.0718, t: 0.03, tone: 0.8 },};const MKEY = ["", "metal", "concrete", "board", "wood"]; // 格子に入れる番号(0 = 空気)

ダイポールの式をそのまま使う

dipoleは、出す電力の平均から双極子モーメントを決める。dipFieldは、本文のと、同じ形の、の複素数の振幅を返す。時刻の値は「実部 + 虚部」で求まる。psiは本文のだ。

ダイポールの場(src/sims/radio-waves.ts の 40〜62 行)
/** 短いダイポール(双極子モーメント p = p0 cos ωt が縦に振動する)。K = p0/4πε0、KB = μ0 ω k p0/4π */export interface Dip { k: number; w: number; lam: number; K: number; KB: number }export function dipole(f: number, P: number): Dip {  // 出す電力の平均 P = μ0 p0² ω⁴ / (12π c) から p0 を決める  const w = TAU * f, k = w / C0, p0 = Math.sqrt((12 * Math.PI * C0 * P) / (MU0 * w ** 4));  return { k, w, lam: C0 / f, K: p0 / (4 * Math.PI * EPS0), KB: (MU0 * w * k * p0) / (4 * Math.PI) };}/** 距離 r・軸からの角 θ の場所の複素振幅。out = [Er, Eθ, Bφ] の実部と虚部。時刻 t の値は 実部·cos ωt + 虚部·sin ωt *  Er = K·2cosθ·(1/r³ − ik/r²)·e^{ikr} *  Eθ = K·sinθ·(1/r³ − ik/r² − k²/r)·e^{ikr} *  Bφ = −KB·sinθ·(1/r)·(1 + i/kr)·e^{ikr} */export function dipField(d: Dip, r: number, ct: number, st: number, out: Float64Array) {  const k = d.k, kr = k * r, c = Math.cos(kr), s = Math.sin(kr);  const a = 1 / (r * r * r), b = k / (r * r), f = (k * k) / r;  const nr = a * c + b * s, ni = a * s - b * c; // (1/r³ − ik/r²)·e^{ikr}  out[0] = d.K * 2 * ct * nr; out[1] = d.K * 2 * ct * ni;  out[2] = d.K * st * (nr - f * c); out[3] = d.K * st * (ni - f * s);  const g = (d.KB * st) / r;  out[4] = -g * (c - s / kr); out[5] = -g * (s + c / kr);}/** 電気力線の関数。この値が同じ点を結ぶと電気力線になる(遠くでは −1〜1 のあいだを動く) *  ψ = sin²θ·[sin(kr − ωt) + cos(kr − ωt)/kr] */export const psi = (kr: number, s2: number, wt: number) => s2 * (Math.sin(kr - wt) + Math.cos(kr - wt) / kr);

この3つが食い違っていないかは、別に計算して確かめた。を数値で微分して作った電場とdipFieldの電場の差は、10億分の1以下。遠くの球の上で電力の流れを足し合わせると0.100 Wになり、遠くのは光の速さと5けたまで一致した。

電気力線を引く

場は上下と左右に対称なので、縦の面の4分の1だけでを格子の点ごとに求める。そこからマーチング・スクエア法で、が同じ値になる線の切れ端を拾い、描くときに残りの4分の3へ写す。アンテナのごく近くでは線が詰まりすぎるので、0.06波長より内側は引かない。

電気力線(src/sims/radio-waves.ts の 64〜119 行)
const DL = 0.34, PMAX = 10; // 線を引く ψ の間隔と、片側の本数の上限/** 縦の面(半径 R の円)の 4 分の 1(s ≥ 0・y ≥ 0)で ψ を格子の点ごとに求め、同じ値を結ぶ線の切れ端を作る(マーチング・スクエア法)。 *  場は上下と左右に対称なので、残りの 4 分の 3 は描くときに写す */function contours(D: DipState, wt: number) {  const { ni, nj, ps, seg } = D, hs = D.R / ni, hy = D.Y / nj, k = D.d.k, rmin = 0.06 * D.d.lam, W = ni + 1;  for (let j = 0; j <= nj; j++) for (let i = 0; i <= ni; i++) {    const s = i * hs, y = j * hy, r = Math.hypot(s, y);    ps[j * W + i] = r < rmin || r > D.R ? 1e9 : psi(k * r, (s * s) / (r * r), wt);  }  let n = 0;  const max = seg.length / 4;  for (let j = 0; j < nj; j++) for (let i = 0; i < ni; i++) {    const a = ps[j * W + i], b = ps[j * W + i + 1], c = ps[(j + 1) * W + i + 1], d = ps[(j + 1) * W + i];    if (a > 1e8 || b > 1e8 || c > 1e8 || d > 1e8) continue;    const lo = Math.max(-PMAX, Math.ceil(Math.min(a, b, c, d) / DL - 0.5)), hi = Math.min(PMAX - 1, Math.floor(Math.max(a, b, c, d) / DL - 0.5));    for (let q = lo; q <= hi && n < max - 2; q++) {      const L = (q + 0.5) * DL;      const id = (a > L ? 1 : 0) | (b > L ? 2 : 0) | (c > L ? 4 : 0) | (d > L ? 8 : 0);      if (id === 0 || id === 15) continue;      // 4 つの辺(0 = 下・1 = 右・2 = 上・3 = 左)の上で ψ = L になる点      const ex = (e: number) => (e === 0 ? i + (L - a) / (b - a) : e === 1 ? i + 1 : e === 2 ? i + (L - d) / (c - d) : i) * hs;      const ey = (e: number) => (e === 0 ? j : e === 1 ? j + (L - b) / (c - b) : e === 2 ? j + 1 : j + (L - a) / (d - a)) * hy;      const put = (e1: number, e2: number) => { const o = 4 * n++; seg[o] = ex(e1); seg[o + 1] = ey(e1); seg[o + 2] = ex(e2); seg[o + 3] = ey(e2); };      const mid = (a + b + c + d) / 4 > L;      switch (id) {        case 1: case 14: put(3, 0); break;        case 2: case 13: put(0, 1); break;        case 3: case 12: put(3, 1); break;        case 4: case 11: put(1, 2); break;        case 6: case 9: put(0, 2); break;        case 7: case 8: put(3, 2); break;        case 5: if (mid) { put(0, 1); put(2, 3); } else { put(3, 0); put(1, 2); } break;        case 10: if (mid) { put(3, 0); put(1, 2); } else { put(0, 1); put(2, 3); } break;      }    }  }  D.nseg = n;}/** 縦の面の点 (s, y) を画面の位置にする式(射影変換)。面の上の点は s·U + y·(上向き) なので、 *  カメラの行列を掛けた結果は s と y の 1 次式になる。h = [x の 3 係数, y の 3 係数, w の 3 係数] */function planeMap(v: View3, U: THREE.Vector3, h: Float64Array) {  const m = TMP_M.multiplyMatrices(v.camera.projectionMatrix, v.camera.matrixWorldInverse).elements;  for (let r = 0; r < 3; r++) {    const row = r === 2 ? 3 : r; // 同次座標の x・y・w の行    h[r * 3] = m[12 + row]; h[r * 3 + 1] = m[row] * U.x + m[8 + row] * U.z; h[r * 3 + 2] = m[4 + row];  }}const TMP_M = new THREE.Matrix4();/** 縦の面の点 → 画面(CSS px)。カメラの後ろなら false */function mapPt(api: Api, h: Float64Array, s: number, y: number, out: number[]): boolean {  const W = h[6] + h[7] * s + h[8] * y;  if (W <= 1e-6) return false;  out[0] = ((h[0] + h[1] * s + h[2] * y) / W + 1) * 0.5 * api.w;  out[1] = (1 - (h[3] + h[4] * s + h[5] * y) / W) * 0.5 * api.h;  return true;}

縦の面の上の点は、面の中心、横向きの単位ベクトル、上向きを使ってと書ける。これにthree.jsのカメラの行列を掛けると、結果はとの1次式になる。planeMapはその係数を1コマに1回だけ求め、mapPtは割り算1回で画面の位置を出す。線は、地の色の太い線の上に、線の色の細い線を重ねて描く。網点の面の上でも読める。

格子で進める

GridがFDTD法の本体だ。電場をますの点に、磁場とを点と点のあいだに置き(Yeeの格子)、stepの中で磁場、電場、アンテナの電流の順に進める。3つのループが、本文の式と、磁場の2つの式、にあたる。

差分の計算(src/sims/radio-waves.ts の 121〜244 行)
/** 水平な面の格子。縦向きの電場 E(ますの点)と、水平な磁場 Hx・Hz(点と点のあいだ)を、半刻みずらして交互に進める(Yee の格子)。 *  磁場には Z0 = √(μ0/ε0) を掛けて電場と同じ単位(V/m)にしてある。こうすると真空での係数が、どちらもクーラン数 S になる。 *  端の吸収層では、E を x の向きの分と z の向きの分に分けて、それぞれに吸収(導電率)を入れる(ベランジェの PML) */export class Grid {  readonly nx: number; readonly nz: number; readonly dx: number; readonly dt: number; readonly w: number; readonly k: number; readonly spp: number;  readonly x0: number; readonly z0: number;  readonly ey: Float32Array; readonly eyx: Float32Array; readonly eyz: Float32Array; readonly hx: Float32Array; readonly hz: Float32Array; readonly avg: Float32Array;  readonly cax: Float32Array; readonly cbx: Float32Array; readonly caz: Float32Array; readonly cbz: Float32Array; readonly mid: Uint8Array;  readonly dax: Float32Array; readonly dbx: Float32Array; readonly daz: Float32Array; readonly dbz: Float32Array;  private readonly aex: Float32Array; private readonly aez: Float32Array; private readonly al: number;  n = 0; src: { c: number; ph: number }[] = [];  constructor(readonly f: number, Lx: number, Lz: number, readonly cpl: number) {    const lam = C0 / f;    this.dx = lam / cpl; this.dt = (S_C * this.dx) / C0; this.w = TAU * f; this.k = TAU / lam; this.spp = cpl / S_C;    const ix = Math.max(8, Math.round(Lx / this.dx)), iz = Math.max(8, Math.round(Lz / this.dx));    const nx = (this.nx = ix + 1 + 2 * NPML), nz = (this.nz = iz + 1 + 2 * NPML), N = nx * nz;    this.x0 = -(ix / 2 + NPML) * this.dx; this.z0 = -(iz / 2 + NPML) * this.dx;    const F = () => new Float32Array(N);    this.ey = F(); this.eyx = F(); this.eyz = F(); this.hx = F(); this.hz = F(); this.avg = F();    this.cax = F(); this.cbx = F(); this.caz = F(); this.cbz = F(); this.mid = new Uint8Array(N);    this.dax = new Float32Array(nx); this.dbx = new Float32Array(nx); this.daz = new Float32Array(nz); this.dbz = new Float32Array(nz);    this.aex = new Float32Array(nx); this.aez = new Float32Array(nz);    // 吸収層の導電率: 内側の縁の 0 から外へ深さの 3 乗で増やす。正面から入った波が層の奥ではね返って戻ってきたとき、    // 振幅が 10⁻⁶ になる強さにする。    // 値は σΔt/2ε0 に直してある(磁場の側は σ* = σ·μ0/ε0 で、同じ値になる)    const amax = (S_C * 4 * Math.log(1e6)) / (4 * NPML);    const prof = (d: number) => amax * Math.min(1, Math.max(0, d)) ** 3;    const fill = (n: number, ae: Float32Array, da: Float32Array, db: Float32Array) => {      const hi = n - 1 - NPML;      for (let i = 0; i < n; i++) {        ae[i] = prof(Math.max(NPML - i, i - hi) / NPML);        const ah = prof(Math.max(NPML - i - 0.5, i + 0.5 - hi) / NPML);        da[i] = (1 - ah) / (1 + ah); db[i] = S_C / (1 + ah);      }    };    fill(nx, this.aex, this.dax, this.dbx); fill(nz, this.aez, this.daz, this.dbz);    for (let j = 0; j < nz; j++) for (let i = 0; i < nx; i++) this.vacuum(j * nx + i, i, j);    this.al = 1 / (3 * this.spp); // 強さの平均は 3 周期ほどの時定数でならす  }  private vacuum(c: number, i: number, j: number) {    const ax = this.aex[i], az = this.aez[j];    this.cax[c] = (1 - ax) / (1 + ax); this.cbx[c] = S_C / (1 + ax);    this.caz[c] = (1 - az) / (1 + az); this.cbz[c] = S_C / (1 + az);    this.mid[c] = 0;  }  /** 1 刻み(Δt)進める */  step() {    const { nx, nz, ey, eyx, eyz, hx, hz, cax, cbx, caz, cbz, dax, dbx, daz, dbz, avg, al } = this;    // 磁場: ∂Hx/∂t = (1/μ0)·∂E/∂z、∂Hz/∂t = −(1/μ0)·∂E/∂x    for (let j = 0; j < nz - 1; j++) { const a = daz[j], b = dbz[j]; for (let c = j * nx, e = c + nx; c < e; c++) hx[c] = a * hx[c] + b * (ey[c + nx] - ey[c]); }    for (let j = 0; j < nz; j++) for (let i = 0, c = j * nx; i < nx - 1; i++, c++) hz[c] = dax[i] * hz[c] - dbx[i] * (ey[c + 1] - ey[c]);    // 電場: ε·∂E/∂t + σE = ∂Hx/∂z − ∂Hz/∂x(いちばん外の点は 0 のまま)    for (let j = 1; j < nz - 1; j++) for (let c = j * nx + 1, e = j * nx + nx - 1; c < e; c++) {      const a = cax[c] * eyx[c] - cbx[c] * (hz[c] - hz[c - 1]);      const b = caz[c] * eyz[c] + cbz[c] * (hx[c] - hx[c - nx]);      eyx[c] = a; eyz[c] = b; const v = a + b; ey[c] = v; avg[c] += al * (v * v - avg[c]);    }    // アンテナの電流 I(t) = I0·sin(ωt + φ)。電流の密度 J = I/Δ² を電場の式に入れる(ΔE = −Δt·J/ε)。最初の 3 周期でゆっくり強める    const t = (this.n + 0.5) * this.dt, T3 = (3 * TAU) / this.w, ramp = t < T3 ? 0.5 - 0.5 * Math.cos((Math.PI * t) / T3) : 1;    const kJ = ((this.dt / (EPS0 * this.dx * this.dx)) * I_ANT * ramp) / S_C;    for (const s of this.src) { const c = s.c; eyx[c] -= kJ * cbx[c] * Math.sin(this.w * t + s.ph); ey[c] = eyx[c] + eyz[c]; }    this.n++;  }  /** 場所 (x, z) にいちばん近い格子の点(吸収層の外に収める) */  cell(x: number, z: number) {    const i = clamp(Math.round((x - this.x0) / this.dx), NPML, this.nx - 1 - NPML), j = clamp(Math.round((z - this.z0) / this.dx), NPML, this.nz - 1 - NPML);    return j * this.nx + i;  }  /** アンテナを置く。n 本目の電流の位相は −n·Δφ(右へ並べると、右ほど遅れる) */  setSources(ants: { x: number; z: number }[], dph: number) { this.src = ants.map((a, n) => ({ c: this.cell(a.x, a.z), ph: -n * dph })); }  /** 配列の値を (x, z) で読む(まわりの 4 点から直線で補う) */  sample(arr: Float32Array, x: number, z: number) {    const fi = clamp((x - this.x0) / this.dx, 0, this.nx - 1.001), fj = clamp((z - this.z0) / this.dx, 0, this.nz - 1.001);    const i = Math.floor(fi), j = Math.floor(fj), u = fi - i, w = fj - j, c = j * this.nx + i;    return (arr[c] * (1 - u) + arr[c + 1] * u) * (1 - w) + (arr[c + this.nx] * (1 - u) + arr[c + this.nx + 1] * u) * w;  }  /** 格子に入れる ε と σ を、格子の上の波が本物の材質の中と同じ波長と減り方で進むように直す。   *  コンクリートの中では波長が 2.3 分の 1 に縮み、1 波長が 3〜4 ます(画質が低いと 3 ます未満)しかなくなる。そのままでは格子の上の波が遅れすぎたり、   *  まったく進めなくなったりする。格子の上の波は「k の代わりに (2/Δ)·sin(kΔ/2)、ω の代わりに (2/Δt)·sin(ωΔt/2)」で   *  ふつうの式に従うので、本物の複素数の波数 k = (ω/c)·√(ε + iσ/ωε0) から逆に ε と σ を決める */  corrected(er: number, sg: number): [number, number] {    const ei = sg / (this.w * EPS0), mod = Math.hypot(er, ei), nr = Math.sqrt((mod + er) / 2), ni = Math.sqrt((mod - er) / 2);    const a = (Math.PI / this.cpl) * nr, b = (Math.PI / this.cpl) * ni; // kΔ/2(複素数)    const sr = Math.sin(a) * Math.cosh(b), si = Math.cos(a) * Math.sinh(b), q = Math.sin((Math.PI * S_C) / this.cpl);    const xr = (S_C * S_C * (sr * sr - si * si)) / (q * q), xi = (S_C * S_C * 2 * sr * si) / (q * q);    return [xr, (xi * EPS0 * (2 / this.dt) * q) / Math.cos((Math.PI * S_C) / this.cpl)];  }  /** 壁を塗る。線分 (x1, z1)-(x2, z2) から厚さの半分より近い点を、材質 key(空なら空気)にする。box なら長方形を塗りつぶす */  paint(x1: number, z1: number, x2: number, z2: number, key: string, th?: number, box = false) {    const m = key ? MATS[key] : null, mi = key ? MKEY.indexOf(key) : 0, dx = this.dx;    const t = th ?? m?.t ?? 0.12;    // 格子より薄い板は線から半ますの内側の点(ふつうは 1 列)に入れ、厚さの比 t/Δ で (ε − 1) と σ を薄める(薄い板の反射と吸収が同じになる)。    // 斜めに描いても、点が角でつながるので電波は抜けない    const thin = !!m && t < 1.5 * dx, half = box ? 0 : thin ? 0.5 * dx : t / 2;    let ca = 1, cb = S_C;    if (m?.pec) { ca = 0; cb = 0; }    else if (m) {      let er = m.a, sg = m.c * (this.f / 1e9) ** m.d;      if (thin) { er = 1 + (er - 1) * (t / dx); sg *= t / dx; }      else [er, sg] = this.corrected(er, sg);      const a = (sg * this.dt) / (2 * EPS0 * er); ca = (1 - a) / (1 + a); cb = S_C / (er * (1 + a));    }    const lim = !m || m.pec ? 1 : NPML; // 金属と空気だけは吸収層の中まで塗れる(どこまでも続く板)    const i0 = Math.max(lim, Math.floor((Math.min(x1, x2) - half - this.x0) / dx)), i1 = Math.min(this.nx - 1 - lim, Math.ceil((Math.max(x1, x2) + half - this.x0) / dx));    const j0 = Math.max(lim, Math.floor((Math.min(z1, z2) - half - this.z0) / dx)), j1 = Math.min(this.nz - 1 - lim, Math.ceil((Math.max(z1, z2) + half - this.z0) / dx));    const ux = x2 - x1, uz = z2 - z1, L2 = ux * ux + uz * uz, h2 = half * half * 1.0001 + 1e-12;    let changed = false;    for (let j = j0; j <= j1; j++) for (let i = i0; i <= i1; i++) {      const px = this.x0 + i * dx, pz = this.z0 + j * dx;      if (box) { if (px < Math.min(x1, x2) || px > Math.max(x1, x2) || pz < Math.min(z1, z2) || pz > Math.max(z1, z2)) continue; }      else {        const s = L2 > 0 ? clamp(((px - x1) * ux + (pz - z1) * uz) / L2, 0, 1) : 0;        const ex = px - x1 - s * ux, ez = pz - z1 - s * uz;        if (ex * ex + ez * ez > h2) continue;      }      const c = j * this.nx + i;      if (mi === 0) { if (this.mid[c] !== 0) { this.vacuum(c, i, j); changed = true; } continue; }      if (this.mid[c] !== mi) changed = true;      this.mid[c] = mi; this.cax[c] = this.caz[c] = ca; this.cbx[c] = this.cbz[c] = cb;      if (m?.pec) { this.ey[c] = this.eyx[c] = this.eyz[c] = 0; }    }    return changed;  }}

磁場にはを掛けて、電場と同じV/mにしてある。こうすると真空での係数がどちらもクーラン数になり、式が短くなる。は0.5で、2次元で発散しない上限の7割ほどだ。端の10ますは吸収層(PML)で、を向きと向きの分に分け、深さの3乗で増える導電率をそれぞれに入れる。

correctedは、コンクリートのように波長が縮む材質の値を直す。格子の上の波は、波数の代わりにで式に従うので、本物の複素数の波数から逆算してとを決める。15 cmのコンクリートの壁に平面の波を当てると、通り抜ける強さは、直さない場合に画質が低で−73 dB、中で−19 dB、高で−14 dBまで狂った。直したあとは中と高で−9.0〜−9.8 dBになり、平面の波の式の−8.8 dBに近い。画質が低いと−11.7 dBで、壁の厚さが8ます(16.7 cm)に丸められ、中の1波長も3ます足らずになるためだ。格子より薄い石膏ボードは、厚さの比でとを薄め、線から半ますの内側の点(ふつうは1列)に入れる。

壁がないときと比べる

測った値の「壁がないときとの比」は、格子の強さの平均と、縦に長い電流の厳密な答えを比べたものだ。ハンケル関数のベッセル関数は、有理式の近似で求める。

測定(src/sims/radio-waves.ts の 246〜294 行)
/** ベッセル関数 J0 と Y0(Numerical Recipes の有理式による近似・誤差は 10⁻⁷ ほど) */export function besJ0(x: number) {  const ax = Math.abs(x);  if (ax < 8) {    const y = x * x;    return (57568490574.0 + y * (-13362590354.0 + y * (651619640.7 + y * (-11214424.18 + y * (77392.33017 + y * -184.9052456))))) /      (57568490411.0 + y * (1029532985.0 + y * (9494680.718 + y * (59272.64853 + y * (267.8532712 + y)))));  }  const z = 8 / ax, y = z * z, xx = ax - 0.785398164;  const p1 = 1 + y * (-0.1098628627e-2 + y * (0.2734510407e-4 + y * (-0.2073370639e-5 + y * 0.2093887211e-6)));  const p2 = -0.1562499995e-1 + y * (0.1430488765e-3 + y * (-0.6911147651e-5 + y * (0.7621095161e-6 - y * 0.934935152e-7)));  return Math.sqrt(0.636619772 / ax) * (Math.cos(xx) * p1 - z * Math.sin(xx) * p2);}export function besY0(x: number) {  if (x < 8) {    const y = x * x;    return (-2957821389.0 + y * (7062834065.0 + y * (-512359803.6 + y * (10879881.29 + y * (-86327.92757 + y * 228.4622733))))) /      (40076544269.0 + y * (745249964.8 + y * (7189466.438 + y * (47447.2647 + y * (226.1030244 + y))))) + 0.636619772 * besJ0(x) * Math.log(x);  }  const z = 8 / x, y = z * z, xx = x - 0.785398164;  const p1 = 1 + y * (-0.1098628627e-2 + y * (0.2734510407e-4 + y * (-0.2073370639e-5 + y * 0.2093887211e-6)));  const p2 = -0.1562499995e-1 + y * (0.1430488765e-3 + y * (-0.6911147651e-5 + y * (0.7621095161e-6 - y * 0.934935152e-7)));  return Math.sqrt(0.636619772 / x) * (Math.sin(xx) * p1 + z * Math.cos(xx) * p2);}/** 壁が無いときの、場所 (x, z) の電場の実効値(V/m)。縦に長い電流 I が作る電場 E = −(ωμ0 I/4)·H0⁽²⁾(kρ) を、 *  アンテナごとの位相をつけて足し合わせる(H0⁽²⁾ = J0 − iY0)。one なら 1 本目だけ */export function freeRms(g: Grid, ants: { x: number; z: number }[], dph: number, x: number, z: number, one: boolean) {  let re = 0, im = 0;  const n = one ? Math.min(1, ants.length) : ants.length;  for (let a = 0; a < n; a++) {    const kr = Math.max(0.3, g.k * Math.hypot(x - ants[a].x, z - ants[a].z)), J = besJ0(kr), Y = besY0(kr), ph = -a * dph;    re += Math.cos(ph) * J + Math.sin(ph) * Y; im += Math.sin(ph) * J - Math.cos(ph) * Y;  }  return ((g.w * MU0 * I_ANT) / 4) * Math.hypot(re, im) / Math.SQRT2;}/** 並べたアンテナの電波の向き: 中心 (cx, cz) から半径 R の弧の上で、強さの平均がいちばん大きい角度(前 = −z から +x の側へ測る) */export function beamAngle(g: Grid, cx: number, cz: number, R: number) {  let best = -1, bi = 0;  const N = 161, v = new Float64Array(N);  for (let n = 0; n < N; n++) {    const th = ((-80 + (160 * n) / (N - 1)) * Math.PI) / 180;    v[n] = g.sample(g.avg, cx + R * Math.sin(th), cz - R * Math.cos(th));    if (v[n] > best) { best = v[n]; bi = n; }  }  // となりの 2 点と放物線でつないで、頂点の角度を細かく求める  let d = 0;  if (bi > 0 && bi < N - 1) { const a = v[bi - 1], b = v[bi], c = v[bi + 1], q = a - 2 * b + c; if (q < 0) d = clamp((0.5 * (a - c)) / q, -0.5, 0.5); }  return -80 + (160 * (bi + d)) / (N - 1);}

壁のない所でアンテナ1本の電波を比べると、違いは画質が高で0.1〜0.6 dB、低で0.5〜1.7 dBだった。並べたアンテナの向きは、中心から1 mの弧の上で強さの平均がいちばん大きい角度を探し、となりの2点と放物線でつないで細かく決める。

描く(three.jsの受け持ち)

水平な面は共通の部品のheightGridで作り、毎コマ高さと面の向きだけを書きかえる。面の向きは、となりの点との高さの差から直接求める。形を作り直さないので、格子が10万ますを超えても1コマの手間は小さい。壁は、同じ材質が横に並んだ列を1つの箱にし、上下の行で同じ列なら箱をつないでから作る。作り直すのは、壁を描いたときだけだ。

描く(src/sims/radio-waves.ts の 346〜605 行)
const HE = 0.24, HP = 0.5; // 面の高さ(波長に対する割合): 電場の山の高さ・強さの平均の高さ/** 電場 → 面の高さ。弱い所はそのまま比例し、強い所は tanh で縮める */const hOfE = (e: number, ed: number, lam: number) => HE * lam * Math.tanh(e / ed);/** 強さの平均(電場の 2 乗の平均) → 面の高さ。基準 er² の −25 dB を 0、+5 dB を 1 にして、そのあいだを dB に比例させる */const hOfP = (p: number, er2: number, lam: number) => HP * lam * clamp((10 * Math.log10(p / er2 + 1e-12) + 25) / 30, 0, 1.2); function teardown(s: S) {  s.root.traverse((o) => { const m = o as THREE.Mesh; if (m.geometry) m.geometry.dispose(); });  s.v.scene.remove(s.root);  s.root = new THREE.Group(); s.v.scene.add(s.root);  s.dip = null; s.fd = null;}/** 光: 波の斜面が読めるよう、太陽は奥の左から低めに当て、空からの光は弱くする */function setShadow(v: View3, half: number, dir: [number, number, number], sky: number) {  const c = v.sun.shadow.camera;  c.left = -half; c.right = half; c.top = half; c.bottom = -half; c.near = 0.05; c.far = half * 8; c.updateProjectionMatrix();  v.sun.target.position.set(0, 0, 0); v.sun.target.updateMatrixWorld();  v.sun.position.copy(new THREE.Vector3(...dir).normalize().multiplyScalar(half * 3));  v.sky.intensity = sky;} /** 1 本のアンテナの場面: 縦の面(電気力線)と、その奥の水平な半円(縦向きの電場の高さ)・放射の強さの形 */function buildDip(api: Api, s: S, donut: boolean) {  const d = dipole(F_DIP, P_DIP), lam = d.lam, q = api.quality;  const R = 3 * lam, Y = R, ni = [64, 100, 140][q], nj = ni;  const cut = new THREE.Group(); s.root.add(cut);  // アンテナ: 長さ 0.15 波長の 2 本の棒(真ん中のすき間から電流を送る)  const al = 0.15 * lam, rod = new THREE.CylinderGeometry(0.0022, 0.0022, al / 2 - 0.002, 12);  for (const sg of [1, -1]) { const m = new THREE.Mesh(rod, s.mats.ant); m.position.y = sg * (al / 4 + 0.001); m.castShadow = true; cut.add(m); }  let surf: THREE.Mesh | null = null;  const nr = [50, 80, 120][q], na = [24, 32, 48][q], r0 = 0.012;  if (!donut) {    const geo = new THREE.BufferGeometry(), nv = (nr + 1) * (na + 1);    geo.setAttribute("position", new THREE.BufferAttribute(new Float32Array(nv * 3), 3));    geo.setAttribute("normal", new THREE.BufferAttribute(new Float32Array(nv * 3), 3));    const idx: number[] = [];    for (let a = 0; a < na; a++) for (let r = 0; r < nr; r++) { const p = a * (nr + 1) + r; idx.push(p, p + 1, p + nr + 1, p + 1, p + nr + 2, p + nr + 1); }    geo.setIndex(idx);    surf = new THREE.Mesh(geo, s.mats.surf); surf.receiveShadow = true; surf.frustumCulled = false; cut.add(surf);  } else {    // 放射の強さの形: 中心からの距離を sin²θ に比例させた回転体の、奥の半分。切り口にはふたをする    const R0 = 2.4 * lam, nt = 64, nf = 24, pos: number[] = [], ix: number[] = [];    for (let f = 0; f <= nf; f++) for (let t = 0; t <= nt; t++) {      const th = (Math.PI * t) / nt, ph = (Math.PI * f) / nf, r = R0 * Math.sin(th) ** 2;      pos.push(r * Math.sin(th) * Math.cos(ph), r * Math.cos(th), -r * Math.sin(th) * Math.sin(ph));    }    for (let f = 0; f < nf; f++) for (let t = 0; t < nt; t++) { const p = f * (nt + 1) + t; ix.push(p, p + nt + 1, p + 1, p + 1, p + nt + 1, p + nt + 2); }    const g = new THREE.BufferGeometry(); g.setAttribute("position", new THREE.Float32BufferAttribute(pos, 3)); g.setIndex(ix); g.computeVertexNormals();    const shell = new THREE.Mesh(g, s.mats.donut); shell.castShadow = true; shell.receiveShadow = true; cut.add(shell);    const cp: number[] = [0, 0, 0], ci: number[] = [];    for (const sg of [1, -1]) {      const b = cp.length / 3;      for (let t = 0; t <= nt; t++) { const th = (Math.PI * t) / nt, r = R0 * Math.sin(th) ** 2; cp.push(sg * r * Math.sin(th), r * Math.cos(th), 0); }      for (let t = 0; t < nt; t++) ci.push(0, b + t, b + t + 1);    }    const cg = new THREE.BufferGeometry(); cg.setAttribute("position", new THREE.Float32BufferAttribute(cp, 3)); cg.setIndex(ci); cg.computeVertexNormals();    cut.add(new THREE.Mesh(cg, s.mats.cap));  }  s.dip = { d, R, Y, ni, nj, ps: new Float32Array((ni + 1) * (nj + 1)), seg: new Float32Array(4 * 60000), nseg: 0, cut, surf, nr, na, r0, hprof: new Float32Array(nr + 1), donut };  setShadow(s.v, R * 1.2, [0.4, 0.8, 0.6], 0.35);  s.v.orbit.minDist = 0.25; s.v.orbit.maxDist = 4;}/** 水平な半円の高さ(真横の面の縦向きの電場は、距離 ρ だけで決まる) */function updateDipSurface(api: Api, s: S, wt: number) {  const D = s.dip!, surf = D.surf; if (!surf) return;  const { nr, na, r0, R, d } = D, H = D.hprof, o = s.tmp, show = api.str("show"), cw = Math.cos(wt), sw = Math.sin(wt);  const ed = (d.K * d.k * d.k) / (1.5 * d.lam), lam = d.lam;  for (let r = 0; r <= nr; r++) {    const rho = r0 + ((R - r0) * r) / nr;    dipField(d, rho, 0, 1, o);    // 真横では θ の向きが真下なので、上向きの電場は −Eθ    H[r] = 2.4 * (show === "p" ? hOfP((o[2] * o[2] + o[3] * o[3]) / 2, (ed * ed) / 2, lam) : hOfE(-(o[2] * cw + o[3] * sw), ed, lam));  }  const pos = surf.geometry.attributes.position as THREE.BufferAttribute, nrm = surf.geometry.attributes.normal as THREE.BufferAttribute;  const P = pos.array as Float32Array, N = nrm.array as Float32Array, dr = (R - r0) / nr;  for (let a = 0; a <= na; a++) {    const ph = (Math.PI * a) / na, c = Math.cos(ph), sn = Math.sin(ph);    for (let r = 0; r <= nr; r++) {      const v = 3 * (a * (nr + 1) + r), rho = r0 + dr * r, sl = (H[Math.min(nr, r + 1)] - H[Math.max(0, r - 1)]) / (dr * (r > 0 && r < nr ? 2 : 1)), L = Math.hypot(1, sl);      P[v] = rho * c; P[v + 1] = H[r]; P[v + 2] = -rho * sn;      N[v] = (-sl * c) / L; N[v + 1] = 1 / L; N[v + 2] = (sl * sn) / L;    }  }  pos.needsUpdate = true; nrm.needsUpdate = true;}/** 画面に、電気力線・磁場の向き・観測点を描く */function drawDip(api: Api, s: S, wt: number, U: THREE.Vector3) {  const D = s.dip!, H = s.H, ctx = api.ctx, p = s.pt, q = [0, 0];  planeMap(s.v, U, H);  contours(D, wt);  // 4 つの象限に写して 1 本の道にまとめ、地の色の太い線の上に線の色の細い線を重ねる(網点の上でも読める)  ctx.beginPath();  for (let n = 0; n < D.nseg; n++) {    const o = 4 * n;    for (const sx of [1, -1]) for (const sy of [1, -1]) {      if (!mapPt(api, H, sx * D.seg[o], sy * D.seg[o + 1], p) || !mapPt(api, H, sx * D.seg[o + 2], sy * D.seg[o + 3], q)) continue;      ctx.moveTo(p[0], p[1]); ctx.lineTo(q[0], q[1]);    }  }  ctx.lineCap = "round"; ctx.setLineDash([]);  ctx.strokeStyle = api.paper; ctx.lineWidth = api.u(3.4); ctx.stroke();  ctx.strokeStyle = api.ink; ctx.lineWidth = api.u(1.3); ctx.stroke();  ctx.lineCap = "butt";  const o = s.tmp, cw = Math.cos(wt), sw = Math.sin(wt), lam = D.d.lam;  // 縦の面の縁(点線の円)  ctx.beginPath();  for (let n = 0; n <= 96; n++) { const a = (TAU * n) / 96; if (mapPt(api, H, D.R * Math.cos(a), D.R * Math.sin(a), p)) { if (n) ctx.lineTo(p[0], p[1]); else ctx.moveTo(p[0], p[1]); } }  ctx.strokeStyle = api.ink; ctx.lineWidth = api.u(1); ctx.setLineDash([api.u(2), api.u(5)]); ctx.stroke(); ctx.setLineDash([]);  // 磁場: 縦の面に垂直なので、手前へ向く所を ⊙、奥へ向く所を ⊗ で描く(アンテナの真横の線の上)。丸の大きさは、遠くの振幅に対する強さ  if (api.bool("bmark") && !D.donut) {    const gs = lam / 4;    for (let sx = -D.R + gs / 2; sx < D.R; sx += gs) {      const r = Math.abs(sx);      if (r < 0.3 * lam) continue;      dipField(D.d, r, 0, 1, o);      const B = o[4] * cw + o[5] * sw, rad = api.u(5.5) * Math.min(1, Math.abs(B) / (D.d.KB / r)) ** 0.7;      if (rad < api.u(1.4) || !mapPt(api, H, sx, 0, p)) continue;      bSym(api, p[0], p[1], rad, (B > 0) === (sx > 0));    }  }  // 観測点: 電場の矢印(長さは、その場所の振幅に対する今の強さ)と、磁場の記号  const [ps, py] = s.probe, r = Math.hypot(ps, py), st = Math.abs(ps) / r, ct = py / r, sg = Math.sign(ps) || 1;  dipField(D.d, r, ct, st, o);  const esR = sg * (o[0] * st + o[2] * ct), esI = sg * (o[1] * st + o[3] * ct), eyR = o[0] * ct - o[2] * st, eyI = o[1] * ct - o[3] * st;  const A = esR * esR + esI * esI + eyR * eyR + eyI * eyI, cR = esR * esR - esI * esI + eyR * eyR - eyI * eyI, cI = 2 * (esR * esI + eyR * eyI);  const eAmp = Math.sqrt((A + Math.hypot(cR, cI)) / 2), bAmp = Math.hypot(o[4], o[5]);  const es = esR * cw + esI * sw, ey = eyR * cw + eyI * sw, B = o[4] * cw + o[5] * sw, L = 0.55 * lam;  if (mapPt(api, H, ps, py, p)) {    api.circle(p[0], p[1], api.u(15), { w: api.u(1.3), dash: [api.u(2), api.u(3)], stroke: "ink" });    api.label("観測点", p[0] - api.u(20), p[1] + api.u(4), { size: api.u(11), align: "right" });    if (mapPt(api, H, ps + (L * es) / eAmp, py + (L * ey) / eAmp, q) && Math.hypot(q[0] - p[0], q[1] - p[1]) > api.u(4)) {      api.arrow(p[0], p[1], q[0], q[1], { w: api.u(4.5), stroke: "paper", head: api.u(13) });      api.arrow(p[0], p[1], q[0], q[1], { w: api.u(2.2), head: api.u(11) });      api.label("E", q[0] + api.u(6), q[1] - api.u(4), { size: api.u(13), font: "math" });    }    const rb = (api.u(10) * Math.abs(B)) / bAmp;    if (rb > api.u(2)) { bSym(api, p[0] + api.u(26), p[1] + api.u(22), rb, (B > 0) === (ps > 0)); api.label("B", p[0] + api.u(26) + rb + api.u(4), p[1] + api.u(26), { size: api.u(13), font: "math" }); }  }  // 目盛り(1 波長)と、アンテナの名前  const yb = -1.08 * D.R, sb = -lam / 2;  if (mapPt(api, H, sb, yb, p) && mapPt(api, H, sb + lam, yb, q)) {    api.line(p[0], p[1], q[0], q[1], { w: api.u(1.6) });    api.line(p[0], p[1] - api.u(5), p[0], p[1] + api.u(5), { w: api.u(1.6) }); api.line(q[0], q[1] - api.u(5), q[0], q[1] + api.u(5), { w: api.u(1.6) });    api.label(`1 波長 = ${fmt(lam * 100, 1)} cm`, q[0] + api.u(8), q[1] + api.u(4), { size: api.u(11.5) });  }  if (mapPt(api, H, 0, -0.1 * lam, p)) api.label("アンテナ", p[0], p[1] + api.u(22), { size: api.u(11.5), align: "center" });  return { r, st, ct, eAmp, bAmp, o, cR, cI };}/** 面に垂直な向きの記号。into = 奥へ(⊗)・そうでなければ手前へ(⊙) */function bSym(api: Api, x: number, y: number, r: number, into: boolean) {  api.circle(x, y, r + api.u(1.5), { stroke: false, fill: "paper" });  api.circle(x, y, r, { w: api.u(1.1) });  if (into) { const k = r * 0.62; api.line(x - k, y - k, x + k, y + k, { w: api.u(1.1) }); api.line(x - k, y + k, x + k, y - k, { w: api.u(1.1) }); }  else api.dot(x, y, Math.max(api.u(1), r * 0.28));} /** 平面の場面: 格子・面・壁・アンテナ・受信機を作る */function buildFd(api: Api, s: S, world: World) {  const g = new Grid(world.f, world.Lx, world.Lz, CPL[api.quality]);  for (const w of world.walls) g.paint(w.x1, w.z1, w.x2, w.z2, w.m, w.t, w.box);  g.setSources(world.ants, (api.num("phase") * Math.PI) / 180);  const ni = g.nx - 2 * NPML, nj = g.nz - 2 * NPML;  const surf = heightGrid(ni - 1, nj - 1, (ni - 1) * g.dx, (nj - 1) * g.dx, s.mats.surf);  surf.mesh.frustumCulled = false; s.root.add(surf.mesh);  const ah = world.wh * 1.3;  const antGeo = new THREE.CylinderGeometry(0.004, 0.004, ah, 10); antGeo.translate(0, ah / 2 - 0.005, 0);  const rxGeo = new THREE.BoxGeometry(0.04, 0.075, 0.01); rxGeo.translate(0, 0.0375, 0);  const rx = new THREE.Mesh(rxGeo, s.mats.rx); rx.castShadow = true; s.root.add(rx);  s.fd = { g, world, surf, hbuf: new Float32Array(ni * nj), ni, nj, walls: [], ants: [], rx, wallsDirty: true, meshDirty: true, lastShow: "", antGeo, antMat: s.mats.ant, beam: NaN, beamAt: -1 };  syncAnts(s);  const big = Math.max(world.Lx, world.Lz);  setShadow(s.v, big * 0.7, [-0.45, 0.55, -0.6], 0.2);  s.v.orbit.minDist = 0.4; s.v.orbit.maxDist = big * 4;}function syncAnts(s: S) {  const F = s.fd!;  while (F.ants.length < F.world.ants.length) { const m = new THREE.Mesh(F.antGeo, F.antMat); m.castShadow = true; s.root.add(m); F.ants.push(m); }  while (F.ants.length > F.world.ants.length) s.root.remove(F.ants.pop()!);  F.world.ants.forEach((a, i) => F.ants[i].position.set(a.x, 0, a.z));  F.rx.position.set(F.world.rx[0], 0, F.world.rx[1]);}/** 格子の材質から壁の立体を作り直す。同じ材質が横に並んだ列を 1 つの箱にし、上下の行で同じ列なら箱をつなぐ */function buildWalls(s: S) {  const F = s.fd!, g = F.g, nx = g.nx, dx = g.dx;  for (const m of F.walls) { s.root.remove(m); m.geometry.dispose(); }  F.walls = [];  for (let mi = 1; mi < MKEY.length; mi++) {    const boxes: number[] = []; // i0, i1, j0, j1    let open = new Map<number, number>(); // i0·4096 + i1 → boxes の中の位置    for (let j = NPML; j < g.nz - NPML; j++) {      const next = new Map<number, number>();      for (let i = NPML; i < nx - NPML; i++) {        if (g.mid[j * nx + i] !== mi) continue;        let e = i; while (e + 1 < nx - NPML && g.mid[j * nx + e + 1] === mi) e++;        const key = i * 4096 + e, b = open.get(key);        if (b !== undefined) { boxes[b + 3] = j; next.set(key, b); } else { next.set(key, boxes.length); boxes.push(i, e, j, j); }        i = e;      }      open = next;    }    if (!boxes.length) continue;    const nb = boxes.length / 4, P = new Float32Array(nb * 60), Nn = new Float32Array(nb * 60), I = new Uint32Array(nb * 30);    const top = F.world.wh * (mi === 3 ? 0.8 : 1);    for (let b = 0; b < nb; b++) {      const x0 = g.x0 + (boxes[4 * b] - 0.5) * dx, x1 = g.x0 + (boxes[4 * b + 1] + 0.5) * dx, z0 = g.z0 + (boxes[4 * b + 2] - 0.5) * dx, z1 = g.z0 + (boxes[4 * b + 3] + 0.5) * dx, y0 = -0.02;      // 上の面と 4 つの側面(下の面は見えないので作らない)      const faces: [number[], number[]][] = [        [[x0, top, z0, x0, top, z1, x1, top, z1, x1, top, z0], [0, 1, 0]],        [[x0, y0, z1, x1, y0, z1, x1, top, z1, x0, top, z1], [0, 0, 1]],        [[x1, y0, z0, x0, y0, z0, x0, top, z0, x1, top, z0], [0, 0, -1]],        [[x1, y0, z1, x1, y0, z0, x1, top, z0, x1, top, z1], [1, 0, 0]],        [[x0, y0, z0, x0, y0, z1, x0, top, z1, x0, top, z0], [-1, 0, 0]],      ];      faces.forEach(([v, n], f) => {        const o = b * 20 + f * 4;        for (let k = 0; k < 4; k++) { P.set(v.slice(3 * k, 3 * k + 3), 3 * (o + k)); Nn.set(n, 3 * (o + k)); }        I.set([o, o + 1, o + 2, o, o + 2, o + 3], b * 30 + f * 6);      });    }    const geo = new THREE.BufferGeometry();    geo.setAttribute("position", new THREE.BufferAttribute(P, 3)); geo.setAttribute("normal", new THREE.BufferAttribute(Nn, 3)); geo.setIndex(new THREE.BufferAttribute(I, 1));    const mesh = new THREE.Mesh(geo, s.mats[MKEY[mi]]); mesh.castShadow = true; mesh.receiveShadow = true; s.root.add(mesh); F.walls.push(mesh);  }}/** 高さの目安にする電場: 壁が無いとき、1 本のアンテナから 0.6 m の所の振幅 */const edOf = (g: Grid) => ((g.w * MU0 * I_ANT) / 4) * Math.sqrt(2 / (Math.PI * g.k * 0.6));/** 面の高さと向き(光の当たり方)を、格子の電場か強さの平均から作る。向きは、となりの点との高さの差から求める */function updateFdSurface(api: Api, s: S) {  const F = s.fd!, g = F.g, { ni, nj } = F, Hb = F.hbuf, show = api.str("show"), lam = C0 / g.f;  const ed = edOf(g), er2 = (ed * ed) / 2;  for (let j = 0, v = 0; j < nj; j++) for (let i = 0, c = (j + NPML) * g.nx + NPML; i < ni; i++, c++, v++) Hb[v] = g.mid[c] ? 0 : show === "p" ? hOfP(g.avg[c], er2, lam) : hOfE(g.ey[c], ed, lam);  const pos = F.surf.geo.attributes.position as THREE.BufferAttribute, nrm = F.surf.geo.attributes.normal as THREE.BufferAttribute;  const P = pos.array as Float32Array, N = nrm.array as Float32Array, k = 1 / (2 * g.dx);  for (let j = 0, v = 0; j < nj; j++) for (let i = 0; i < ni; i++, v++) {    const hx = (Hb[i < ni - 1 ? v + 1 : v] - Hb[i > 0 ? v - 1 : v]) * k, hz = (Hb[j < nj - 1 ? v + ni : v] - Hb[j > 0 ? v - ni : v]) * k, L = 1 / Math.sqrt(1 + hx * hx + hz * hz);    P[3 * v + 1] = Hb[v]; N[3 * v] = -hx * L; N[3 * v + 1] = L; N[3 * v + 2] = -hz * L;  }  pos.needsUpdate = true; nrm.needsUpdate = true;}function drawFd(api: Api, s: S) {  const F = s.fd!, W = F.world, v = s.v, lab = { size: api.u(11.5), align: "center" as const };  const pr = (x: number, y: number, z: number) => v.project([x, y, z]);  for (const [x, z, t] of W.labels) { const p = pr(x, W.wh * 1.05, z); if (p.on) api.label(t, p.x, p.y - api.u(8), lab); }  // アンテナの取っ手(四角)と受信機の取っ手(点線の輪)  const hs = api.u(W.ants.length > 4 ? 5 : 8);  for (const a of W.ants) { const p = pr(a.x, 0, a.z); if (p.on) api.rect(p.x - hs, p.y - hs, 2 * hs, 2 * hs, { w: api.u(1.4) }); }  const pRx = pr(W.rx[0], 0.04, W.rx[1]);  if (pRx.on) { api.circle(pRx.x, pRx.y, api.u(16), { w: api.u(1.3), dash: [api.u(2), api.u(3)] }); api.label("受信機", pRx.x, pRx.y + api.u(32), lab); }  // 目盛り 1 m(面の手前の縁)  const z = W.Lz / 2 - 0.06, x0 = -W.Lx / 2 + 0.1, a = pr(x0, 0, z), b = pr(x0 + 1, 0, z);  if (a.on && b.on) { api.line(a.x, a.y, b.x, b.y, { w: api.u(1.8) }); api.label("1 m", (a.x + b.x) / 2, (a.y + b.y) / 2 + api.u(18), lab); }  // 並べたアンテナの電波の向き: 測った向き(実線)と式の向き(破線)  if (W.beam) {    const B = W.beam, c = pr(B.cx, 0.01, B.cz), lam = C0 / W.f, sn = clamp(((api.num("phase") / 360) * lam) / B.d, -1, 1), th = Math.asin(sn);    const end = (t: number) => pr(B.cx + B.R * Math.sin(t), 0.01, B.cz - B.R * Math.cos(t));    const e1 = end(th);    if (c.on && e1.on) api.line(c.x, c.y, e1.x, e1.y, { w: api.u(1.6), dash: [api.u(6), api.u(5)] });    if (Number.isFinite(F.beam)) { const e2 = end((F.beam * Math.PI) / 180); if (c.on && e2.on) { api.line(c.x, c.y, e2.x, e2.y, { w: api.u(2.4), stroke: "paper" }); api.line(c.x, c.y, e2.x, e2.y, { w: api.u(1.4) }); api.label("測った向き", e2.x, e2.y - api.u(10), lab); } }  }}

1本のアンテナの場面では、縦の面をいつもカメラのいる側へ向け、水平な半円をその奥に置く。手前にあると、下半分の線を隠してしまうからだ。電気力線、観測点の矢印、磁場の記号、文字は、v.renderのあとに2Dの描画面へ重ねる。

指の操作

指を置いた所に近い順に、アンテナの四角、受信機の輪、観測点の輪を探してつかむ。何もつかまなければ、「壁を描く」が入っていれば壁を塗り、入っていなければ視点を回す。動かさずに離すと「たたいた」とみなし、空いた面ならアンテナを足し、アンテナの上なら外す。

操作(src/sims/radio-waves.ts の 607〜630 行)
const hitDist = (api: Api, x: number, y: number) => Math.hypot(api.pointer.x - x, api.pointer.y - y);/** 指の下の、水平な面(高さ 0)の点。格子の中(吸収層の外)なら返す */function floorAt(api: Api, s: S): [number, number] | null {  const W = s.world!, p = s.v.onPlane(api.pointer.x, api.pointer.y, new THREE.Plane(new THREE.Vector3(0, 1, 0), 0));  if (!p || Math.abs(p.x) > W.Lx / 2 || Math.abs(p.z) > W.Lz / 2) return null;  return [p.x, p.z];}/** 観測点を、指の下の縦の面の点へ動かす(アンテナに近づきすぎない) */function moveProbe(api: Api, s: S) {  const D = s.dip!, rot = D.cut.rotation.y, n = new THREE.Vector3(Math.sin(rot), 0, Math.cos(rot));  const p = s.v.onPlane(api.pointer.x, api.pointer.y, new THREE.Plane(n, 0));  if (!p) return;  let ps = clamp(p.x * Math.cos(rot) - p.z * Math.sin(rot), -D.R, D.R), py = clamp(p.y, -D.Y, D.Y);  const r = Math.hypot(ps, py), rmin = 0.08 * D.d.lam;  if (r < rmin) { if (r > 1e-9) { ps *= rmin / r; py *= rmin / r; } else ps = rmin; }  s.probe = [ps, py];}/** 指でなぞった線分に壁を塗る(「消す」なら空気に戻す) */function paintTo(s: S, x: number, z: number, key: string) {  const F = s.fd!, d = s.drag!, m = key === "erase" ? "" : key, t = key === "erase" ? 0.14 : undefined;  const w: Wall = { x1: d.lx, z1: d.lz, x2: x, z2: z, m, t };  if (F.g.paint(w.x1, w.z1, w.x2, w.z2, w.m, w.t)) F.wallsDirty = true;  s.world!.walls.push(w); d.lx = x; d.lz = z;}

描いた壁とアンテナの位置は、格子のますではなくmの座標で覚えておく。画質を変えて格子を作り直しても、同じ場所に塗り直せる。

画面の部品(描画・場面の切り替え・画質の自動調整・GPU の網点)は全記事で共通です。シミュレーションの共通の仕組みで解説しています。

プログラム全体を見る(src/sims/radio-waves.ts・814 行)

読み込んでいます

姉妹サイト

株式会社Game が運営するメディアです。