2024/03/06(水) [n年前の日記]
#1 [python] Python + glfwで疑似3D道路の描画実験中。その4
Windows10 x64 22H2上で、Python 3.10.10 + glfw 2.7.0 + PyOpenGL 3.1.6 を使って勉強中。疑似3D道路を描画してみたい。
どうにかそれらしく描画できるようになってきた気がする。
どうにかそれらしく描画できるようになってきた気がする。
◎ 線だけで描画 :
ひとまず、セグメント(道路データ)を、線だけで描画する感じで処理してみた。これなら基本的な処理が分かりやすくなるはず。
ソースは以下。
_06_ps3d.py
一応、色々説明しておく。
動作には以下が必要。
Python 3.x がインストールされている環境なら、pip でインストールできるのではないかな…。
PyOpenGL については、以下から .whl ファイルを入手してインストールした。以下で入手できる版には、Python のバージョンと一致している freeglut*.dll も同梱されているので、別途 freeglut.dll をインストールしなくても GLUT が使える状態になる。まあ、今回は GLUT を使ってないけど…。
_Archived: Python Extension Packages for Windows - Christoph Gohlke
疑似3D道路の仕組みについて簡単に説明。いやまあ、「今の時代に疑似3D道路なんてわざわざやらんでもええやろ」と言われそうだけど、一応…。
一般的に、自分達が、カーブを描いた道路データを用意したいと思った際は、上図で言えば左側のような状態を考えるわけだけど。疑似3D道路は、右側のようなデータを持つことで、カーブを描いてるように見せかけてる。
左側のような道路データを持つと、カメラなり道路なりを回転させるための計算が入ってくるけれど、右側なら、横方向、もしくは縦方向に位置をずらしているだけなので、回転処理が入ってこなくて計算が簡単になる。また、昔のTVゲーム機は、ラスター単位でBG(背景画面)の表示位置を変更できたけれど、そういった、(今と比べたら)非力なハードウェアにとっても、表示位置を上下左右にずらすだけでそれらしく見えてくるこのやり方は都合が良かった。まあ、頓智ですわな…。
余談。疑似3D道路の描画実験をしていて思ったのは、まず道路データ(巷の英文解説記事では segment と呼んでることが多い)を用意することかなと…。まずは直線の道路だけでもいいからセグメントデータを用意して、それを透視変換で表示してみると、なんだか作れそうな気がしてくるというか…。後はコレをイイ感じに横や縦にずらして表示してやればええのやな、簡単やん、みたいな。まあ、実際にやってみると細かいところで「アレ? こんなはずでは」と悩む場面がちょくちょく出てくるけど…。
ソースは以下。
_06_ps3d.py
from OpenGL.GL import * from OpenGL.GLU import * import glfw SCRW, SCRH = 1280, 720 class Gwk: """Global work class""" def __init__(self): global SCRW, SCRH self.running = True self.scrw = SCRW self.scrh = SCRH self.seg_length = 5.0 self.view_distance = 160 self.camera_z = 0.0 self.spd = self.seg_length * 0.1 self.segdata_src = [ {"cnt": 20, "curve": 0.0, "pitch": 0.0}, {"cnt": 10, "curve": -0.4, "pitch": 0.0}, {"cnt": 20, "curve": 0.0, "pitch": 0.0}, {"cnt": 5, "curve": 2.0, "pitch": 0.0}, {"cnt": 20, "curve": 0.0, "pitch": 0.4}, {"cnt": 5, "curve": 0.0, "pitch": 0.0}, {"cnt": 10, "curve": -1.0, "pitch": 0.0}, {"cnt": 15, "curve": 0.0, "pitch": -0.5}, {"cnt": 10, "curve": 0.0, "pitch": 0.3}, {"cnt": 20, "curve": -0.2, "pitch": 0.0}, {"cnt": 10, "curve": 1.0, "pitch": 0.0}, {"cnt": 5, "curve": -0.4, "pitch": 0.4}, {"cnt": 10, "curve": 0.0, "pitch": -0.6}, {"cnt": 5, "curve": 0.1, "pitch": 0.3}, {"cnt": 10, "curve": 0.0, "pitch": -0.5}, {"cnt": 20, "curve": 0.0, "pitch": 0.0}, ] # count segment number self.seg_max = 0 for d in self.segdata_src: self.seg_max += d["cnt"] self.seg_total_length = self.seg_length * self.seg_max # expand segment data z = 0.0 self.segdata = [] for i in range(len(self.segdata_src)): d0 = self.segdata_src[i] d1 = self.segdata_src[(i + 1) % len(self.segdata_src)] cnt = d0["cnt"] curve = d0["curve"] pitch = d0["pitch"] next_curve = d1["curve"] next_pitch = d1["pitch"] for j in range(cnt): ratio = j / cnt c = curve + ((next_curve - curve) * ratio) p = pitch + ((next_pitch - pitch) * ratio) self.segdata.append( { "z": z, "curve": c, "pitch": p, } ) z += self.seg_length gw = Gwk() def keyboard(window, key, scancode, action, mods): global gw if key == glfw.KEY_Q or key == glfw.KEY_ESCAPE: # ESC key or Q key to exit gw.running = False def render(): global gw # move camera gw.camera_z += gw.spd if gw.camera_z >= gw.seg_total_length: gw.camera_z -= gw.seg_total_length # get segment index idx = 0 if gw.camera_z != 0.0: idx = int(gw.camera_z / gw.seg_length) % gw.seg_max if idx < 0: idx += gw.seg_max z = gw.segdata[idx]["z"] curve = gw.segdata[idx]["curve"] pitch = gw.segdata[idx]["pitch"] ccz = gw.camera_z % gw.seg_total_length camz = (ccz - z) / gw.seg_length xd = -camz * curve yd = -camz * pitch zd = gw.seg_length cx = -(xd * camz) cy = -(yd * camz) cz = z - ccz road_y = -10.0 dt = [] for k in range(gw.view_distance): dt.append({"x": cx, "y": (cy + road_y), "z": cz}) cx += xd cy += yd cz += zd i = (idx + k) % gw.seg_max xd += gw.segdata[i]["curve"] yd += gw.segdata[i]["pitch"] # clear screen glClearColor(0, 0, 0, 1) glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT) glLoadIdentity() glTranslatef(0, 0, 0) # draw lines w = 20 dt.reverse() glColor4f(1.0, 1.0, 1.0, 1.0) glBegin(GL_LINES) for d in dt: x, y, z = d["x"], d["y"], d["z"] glVertex3f(x - w, y, -z) glVertex3f(x + w, y, -z) glEnd() def main(): global gw if not glfw.init(): raise RuntimeError("Could not initialize GLFW3") window = glfw.create_window(gw.scrw, gw.scrh, "lines", None, None) if not window: glfw.terminate() raise RuntimeError("Could not create an window") glfw.set_key_callback(window, keyboard) glfw.make_context_current(window) glfw.swap_interval(1) glViewport(0, 0, gw.scrw, gw.scrh) glMatrixMode(GL_PROJECTION) glLoadIdentity() gluPerspective(75.0, gw.scrw / gw.scrh, 2.5, -1000.0) glMatrixMode(GL_MODELVIEW) gw.camera_z = 0.0 gw.running = True # main loop while not glfw.window_should_close(window) and gw.running: render() glfw.swap_buffers(window) glfw.poll_events() glfw.terminate() if __name__ == "__main__": main()
一応、色々説明しておく。
動作には以下が必要。
- Python 3.10.10 64bit
- PyOpenGL 3.1.6
- glfw 2.7.0
Python 3.x がインストールされている環境なら、pip でインストールできるのではないかな…。
python -m pip install glfw or pip install glfw
PyOpenGL については、以下から .whl ファイルを入手してインストールした。以下で入手できる版には、Python のバージョンと一致している freeglut*.dll も同梱されているので、別途 freeglut.dll をインストールしなくても GLUT が使える状態になる。まあ、今回は GLUT を使ってないけど…。
_Archived: Python Extension Packages for Windows - Christoph Gohlke
- PyOpenGL-3.1.6-cp310-cp310-win_amd64.whl
- PyOpenGL_accelerate-3.1.6-cp310-cp310-win_amd64.whl
pip install PyOpenGL-3.1.6-cp310-cp310-win_amd64.whl pip install PyOpenGL_accelerate-3.1.6-cp310-cp310-win_amd64.whl
疑似3D道路の仕組みについて簡単に説明。いやまあ、「今の時代に疑似3D道路なんてわざわざやらんでもええやろ」と言われそうだけど、一応…。
一般的に、自分達が、カーブを描いた道路データを用意したいと思った際は、上図で言えば左側のような状態を考えるわけだけど。疑似3D道路は、右側のようなデータを持つことで、カーブを描いてるように見せかけてる。
左側のような道路データを持つと、カメラなり道路なりを回転させるための計算が入ってくるけれど、右側なら、横方向、もしくは縦方向に位置をずらしているだけなので、回転処理が入ってこなくて計算が簡単になる。また、昔のTVゲーム機は、ラスター単位でBG(背景画面)の表示位置を変更できたけれど、そういった、(今と比べたら)非力なハードウェアにとっても、表示位置を上下左右にずらすだけでそれらしく見えてくるこのやり方は都合が良かった。まあ、頓智ですわな…。
余談。疑似3D道路の描画実験をしていて思ったのは、まず道路データ(巷の英文解説記事では segment と呼んでることが多い)を用意することかなと…。まずは直線の道路だけでもいいからセグメントデータを用意して、それを透視変換で表示してみると、なんだか作れそうな気がしてくるというか…。後はコレをイイ感じに横や縦にずらして表示してやればええのやな、簡単やん、みたいな。まあ、実際にやってみると細かいところで「アレ? こんなはずでは」と悩む場面がちょくちょく出てくるけど…。
◎ テクスチャを貼ったり塗りつぶしたりしてみた :
線だけで道路を描画することができたので、道路にテクスチャを貼ったり、地面を塗りつぶしたりしてみた。ここまでできれば、後は背景とビルボードを出すだけでもうちょっとそれらしくなるかなと。
ソースは以下。ちょっと長くなってしまった…。
_08_ps3d_tex.py
使用画像は以下。256x256, 32bit(RGBA)のpng画像。コレを道路のテクスチャ画像として使ってる。
_road.png
少し説明。テクスチャ画像の読み込みには Pillow (PIL) 10.2.0 を使ってる。pip でインストールできる。
今回、地面を塗り潰すところでちょっと悩んでしまった。2D描画を前提とした画面なら、「画面の左端から右端まで塗り潰せ」だけで済むけれど。OpenGL のような3D空間でそういう見た目を実現するにはどうしたらいいんだろうと…。
画面の左端から右端まで覆いつくすような四角ポリゴンを置けば目的が果たせるのではないかと考えたけれど、画面端のx座標を求めるところで悩んでしまった。
x座標を求める手順としては以下。縦方向の視野角と、ウインドウの横幅、縦幅から、横方向の視野角を求めて…。描画したい位置のz値は分かってるから、tan(横方向の視野角 / 2) * z値、で、求める x座標値が得られる。
縦方向の視野角は gluPerspective() を呼ぶ際に指定しているから既に分かっているし、ウインドウの横幅と縦幅はウインドウを作るための glfw.create_window() を呼ぶ際に指定してるからこれも分かっているはず。
というかこのあたり、昔やってた…。完全に忘れてた。もうダメだ自分。
_mieki256's diary - Processingを使って cube map とやらのテスト
余談。最初はフラットな見た目の画像を使って実験していたのだけど、ポリゴンの繋ぎ目のところにチラチラとテクスチャのゴミが表示されたり、境界がうっすらと見えてしまったりして、コレが気になって気になって…。ノイズだらけの画像と差し替えてみたらそれほど気にならなくなったけれど、本来はどうやって解決すべきなのだろう…?
ソースは以下。ちょっと長くなってしまった…。
_08_ps3d_tex.py
from OpenGL.GL import * from OpenGL.GLU import * import glfw from PIL import Image import math SCRW, SCRH = 1280, 720 class Gwk: """Global work""" def __init__(self): global SCRW, SCRH self.running = True self.scrw = SCRW self.scrh = SCRH self.seg_length = 7.5 self.view_distance = 160 self.fovy = 70.0 self.fovx = self.fovy * self.scrw / self.scrh self.znear = self.seg_length * 0.7 self.zfar = self.seg_length * (self.view_distance + 2) self.camera_z = 0.0 self.spd = self.seg_length * 0.1 self.segdata_src = [ {"cnt": 20, "curve": 0.0, "pitch": 0.0}, {"cnt": 10, "curve": -0.4, "pitch": 0.0}, {"cnt": 20, "curve": 0.0, "pitch": 0.0}, {"cnt": 5, "curve": 2.0, "pitch": 0.0}, {"cnt": 20, "curve": 0.0, "pitch": 0.4}, {"cnt": 5, "curve": 0.0, "pitch": 0.0}, {"cnt": 10, "curve": -1.0, "pitch": 0.0}, {"cnt": 15, "curve": 0.0, "pitch": -0.5}, {"cnt": 10, "curve": 0.0, "pitch": 0.3}, {"cnt": 20, "curve": -0.2, "pitch": 0.0}, {"cnt": 10, "curve": 1.0, "pitch": 0.0}, {"cnt": 5, "curve": -0.4, "pitch": 0.4}, {"cnt": 10, "curve": 0.0, "pitch": -0.6}, {"cnt": 5, "curve": 0.1, "pitch": 0.3}, {"cnt": 10, "curve": 0.0, "pitch": -0.5}, {"cnt": 20, "curve": 0.0, "pitch": 0.0}, ] # count segment number self.seg_max = 0 for d in self.segdata_src: self.seg_max += d["cnt"] self.seg_total_length = self.seg_length * self.seg_max # expand segment data z = 0.0 self.segdata = [] for i in range(len(self.segdata_src)): d0 = self.segdata_src[i] d1 = self.segdata_src[(i + 1) % len(self.segdata_src)] cnt = d0["cnt"] curve = d0["curve"] pitch = d0["pitch"] next_curve = d1["curve"] next_pitch = d1["pitch"] for j in range(cnt): ratio = j / cnt c = curve + ((next_curve - curve) * ratio) p = pitch + ((next_pitch - pitch) * ratio) self.segdata.append( { "z": z, "curve": c, "pitch": p, } ) z += self.seg_length def load_image(self): im = Image.open("road.png").convert("RGBA") w, h = im.size data = im.tobytes() self.road_tex = glGenTextures(1) glBindTexture(GL_TEXTURE_2D, self.road_tex) glPixelStorei(GL_UNPACK_ALIGNMENT, 4) glTexImage2D( GL_TEXTURE_2D, 0, GL_RGBA, w, h, 0, GL_RGBA, GL_UNSIGNED_BYTE, data ) gw = Gwk() def keyboard(window, key, scancode, action, mods): global gw if key == glfw.KEY_Q or key == glfw.KEY_ESCAPE: # ESC key or Q key to exit gw.running = False def resize(window, w, h): if h == 0: return global gw gw.scrw = w gw.scrh = h glViewport(0, 0, w, h) gw.fovx = gw.fovy * gw.scrw / gw.scrh def render(): global gw # move camera gw.camera_z += gw.spd if gw.camera_z >= gw.seg_total_length: gw.camera_z -= gw.seg_total_length # get segment index idx = 0 if gw.camera_z != 0.0: idx = int(gw.camera_z / gw.seg_length) % gw.seg_max if idx < 0: idx += gw.seg_max z = gw.segdata[idx]["z"] curve = gw.segdata[idx]["curve"] pitch = gw.segdata[idx]["pitch"] ccz = gw.camera_z % gw.seg_total_length camz = (ccz - z) / gw.seg_length xd = -camz * curve yd = -camz * pitch zd = gw.seg_length cx = -(xd * camz) cy = -(yd * camz) cz = z - ccz road_y = -10.0 dt = [] for k in range(gw.view_distance): i = (idx + k) % gw.seg_max a = i % 4 dt.append({"x": cx, "y": (cy + road_y), "z": cz, "attr": a}) cx += xd cy += yd cz += zd xd += gw.segdata[i]["curve"] yd += gw.segdata[i]["pitch"] # init OpenGL glViewport(0, 0, gw.scrw, gw.scrh) glMatrixMode(GL_PROJECTION) glLoadIdentity() gluPerspective(gw.fovy, gw.scrw / gw.scrh, gw.znear, gw.zfar) glMatrixMode(GL_MODELVIEW) # clear screen glClearColor(0, 0, 0, 1) glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT) glEnable(GL_CULL_FACE) glCullFace(GL_BACK) glLoadIdentity() glTranslatef(0, 0, 0) glEnable(GL_TEXTURE_2D) glBindTexture(GL_TEXTURE_2D, gw.road_tex) glTexParameterf(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP) glTexParameterf(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_REPEAT) glTexParameterf(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR) glTexParameterf(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR) glTexEnvf(GL_TEXTURE_ENV, GL_TEXTURE_ENV_MODE, GL_REPLACE) # draw roads w = 20 tanv = math.tan(math.radians(gw.fovx) / 2.0) dt.reverse() for i in range(len(dt) - 1): d0 = dt[i] d1 = dt[i + 1] x0, y0, z0, a0 = d0["x"], d0["y"], d0["z"], d0["attr"] x1, y1, z1 = d1["x"], d1["y"], d1["z"] gndw0 = tanv * z0 gndw1 = tanv * z1 # draw ground glDisable(GL_TEXTURE_2D) if a0 % 2 == 0: glColor4f(0.45, 0.70, 0.25, 1) else: glColor4f(0.10, 0.68, 0.25, 1) glBegin(GL_QUADS) glVertex3f(+gndw0, y0, -z0) glVertex3f(-gndw0, y0, -z0) glVertex3f(-gndw1, y1, -z1) glVertex3f(+gndw1, y1, -z1) glEnd() # draw road glEnable(GL_TEXTURE_2D) glBegin(GL_QUADS) v0 = a0 * 0.25 v1 = v0 + 0.25 glColor4f(1, 1, 1, 1) glTexCoord2f(0.0, v0) glVertex3f(x0 + w, y0, -z0) glTexCoord2f(1.0, v0) glVertex3f(x0 - w, y0, -z0) glTexCoord2f(1.0, v1) glVertex3f(x1 - w, y1, -z1) glTexCoord2f(0.0, v1) glVertex3f(x1 + w, y1, -z1) glEnd() glDisable(GL_TEXTURE_2D) def main(): global gw if not glfw.init(): raise RuntimeError("Could not initialize GLFW3") window = glfw.create_window(gw.scrw, gw.scrh, "lines", None, None) if not window: glfw.terminate() raise RuntimeError("Could not create an window") glfw.set_key_callback(window, keyboard) glfw.set_window_size_callback(window, resize) glfw.make_context_current(window) glfw.swap_interval(1) gw.load_image() gw.camera_z = 0.0 gw.running = True # main loop while not glfw.window_should_close(window) and gw.running: render() glFlush() glfw.swap_buffers(window) glfw.poll_events() glfw.terminate() if __name__ == "__main__": main()
使用画像は以下。256x256, 32bit(RGBA)のpng画像。コレを道路のテクスチャ画像として使ってる。
_road.png
少し説明。テクスチャ画像の読み込みには Pillow (PIL) 10.2.0 を使ってる。pip でインストールできる。
python -m pip install pillow
今回、地面を塗り潰すところでちょっと悩んでしまった。2D描画を前提とした画面なら、「画面の左端から右端まで塗り潰せ」だけで済むけれど。OpenGL のような3D空間でそういう見た目を実現するにはどうしたらいいんだろうと…。
画面の左端から右端まで覆いつくすような四角ポリゴンを置けば目的が果たせるのではないかと考えたけれど、画面端のx座標を求めるところで悩んでしまった。
x座標を求める手順としては以下。縦方向の視野角と、ウインドウの横幅、縦幅から、横方向の視野角を求めて…。描画したい位置のz値は分かってるから、tan(横方向の視野角 / 2) * z値、で、求める x座標値が得られる。
縦方向の視野角は gluPerspective() を呼ぶ際に指定しているから既に分かっているし、ウインドウの横幅と縦幅はウインドウを作るための glfw.create_window() を呼ぶ際に指定してるからこれも分かっているはず。
というかこのあたり、昔やってた…。完全に忘れてた。もうダメだ自分。
_mieki256's diary - Processingを使って cube map とやらのテスト
余談。最初はフラットな見た目の画像を使って実験していたのだけど、ポリゴンの繋ぎ目のところにチラチラとテクスチャのゴミが表示されたり、境界がうっすらと見えてしまったりして、コレが気になって気になって…。ノイズだらけの画像と差し替えてみたらそれほど気にならなくなったけれど、本来はどうやって解決すべきなのだろう…?
◎ 余談 :
「せっかく OpenGL (PyOpenGL) を使ってるなら疑似3D道路なんてやらずにフツーに3D空間に道路を置いて描画すればええやん」と言われそうだけど、それは昔に Processing (Proce55ing) や three.js を使って既にやっておりまして。
_mieki256's diary - three.jsで一本道を延々と走るソレ
_mieki256's diary - Processingで一本道の道路の生成をテスト
_mieki256's diary - Processingで一本道の道路を延々走るソレを弄ってたり
なんというか、もっとレトロな雰囲気の画面を出してみたいわけですよ…。
_mieki256's diary - three.jsで一本道を延々と走るソレ
_mieki256's diary - Processingで一本道の道路の生成をテスト
_mieki256's diary - Processingで一本道の道路を延々走るソレを弄ってたり
なんというか、もっとレトロな雰囲気の画面を出してみたいわけですよ…。
[ ツッコむ ]
以上です。