quake-rb 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -23,15 +23,49 @@ module Quake
23
23
  puts "Uploaded #{@gl_textures.size} textures to GL"
24
24
  end
25
25
 
26
- def bind(miptex_index)
27
- tex_id = @gl_textures[miptex_index]
26
+ def bind(miptex_index, time: 0.0, alternate: false)
27
+ tex_id = @gl_textures[animated_miptex_index(miptex_index, time: time, alternate: alternate)]
28
28
  GL.BindTexture(GL::TEXTURE_2D, tex_id || 0)
29
29
  end
30
30
 
31
+ # Fence/grate textures ('{' prefix) have palette index 255 cut out
32
+ # and are drawn with alpha test (Quakespasm SURF_DRAWFENCE).
33
+ def fence?(miptex_index)
34
+ @level&.textures&.[](miptex_index)&.name&.start_with?("{") || false
35
+ end
36
+
31
37
  private
32
38
 
39
+ def animated_miptex_index(miptex_index, time:, alternate:)
40
+ base_index = miptex_index
41
+ base = @level&.textures&.[](base_index)
42
+ return base_index unless base
43
+
44
+ if alternate && base.alternate_anims
45
+ base_index = base.alternate_anims
46
+ base = @level.textures[base_index]
47
+ return miptex_index unless base
48
+ end
49
+
50
+ return base_index if base.anim_total.to_i.zero?
51
+
52
+ relative = (time.to_f * 10).to_i % base.anim_total
53
+ count = 0
54
+ while base.anim_min > relative || base.anim_max <= relative
55
+ base_index = base.anim_next
56
+ raise "R_TextureAnimation: broken cycle" if base_index.nil?
57
+
58
+ base = @level.textures[base_index]
59
+ raise "R_TextureAnimation: broken cycle" unless base
60
+ raise "R_TextureAnimation: infinite cycle" if (count += 1) > 100
61
+ end
62
+
63
+ base_index
64
+ end
65
+
33
66
  def upload_texture(miptex)
34
- rgba = @palette.indexed_to_rgba(miptex.pixels)
67
+ transparent = miptex.name.start_with?("{") ? 255 : nil
68
+ rgba = @palette.indexed_to_rgba(miptex.pixels, transparent_index: transparent)
35
69
 
36
70
  buf = "\0" * 4
37
71
  GL.GenTextures(1, buf)
@@ -43,7 +77,7 @@ module Quake
43
77
  GL::RGBA, GL::UNSIGNED_BYTE, rgba)
44
78
  GL.TexParameteri(GL::TEXTURE_2D, GL::TEXTURE_WRAP_S, GL::REPEAT)
45
79
  GL.TexParameteri(GL::TEXTURE_2D, GL::TEXTURE_WRAP_T, GL::REPEAT)
46
- GL.TexParameteri(GL::TEXTURE_2D, GL::TEXTURE_MIN_FILTER, GL::LINEAR_MIPMAP_LINEAR)
80
+ GL.TexParameteri(GL::TEXTURE_2D, GL::TEXTURE_MIN_FILTER, GL::LINEAR_MIPMAP_NEAREST)
47
81
  GL.TexParameteri(GL::TEXTURE_2D, GL::TEXTURE_MAG_FILTER, GL::LINEAR)
48
82
  GL.GenerateMipmap(GL::TEXTURE_2D)
49
83
 
@@ -15,6 +15,7 @@ module Quake
15
15
  @water = water
16
16
  @last_leaf = -1
17
17
  @visible_surface_cache = nil
18
+ @time = 0.0
18
19
 
19
20
  # Precompute ALL surfaces grouped by texture (for non-VIS fallback
20
21
  # and as source data for VIS filtering)
@@ -24,12 +25,17 @@ module Quake
24
25
  puts "Precomputed #{@all_surfaces_by_texture.values.sum(&:size)} surfaces across #{@all_surfaces_by_texture.size} textures"
25
26
  end
26
27
 
27
- def render(camera, aspect)
28
+ def render(camera, aspect, time: @time || 0.0)
29
+ @time = time.to_f
30
+
28
31
  GL.Clear(GL::COLOR_BUFFER_BIT | GL::DEPTH_BUFFER_BIT)
29
32
 
30
33
  camera.apply_projection_gl(aspect)
31
34
  camera.apply_gl
32
35
 
36
+ # 3D scene culls front faces (Quake winding); 2D passes disable this
37
+ GL.Enable(GL::CULL_FACE)
38
+
33
39
  # Determine visible surfaces via PVS
34
40
  surfaces_by_texture = visible_surfaces(camera)
35
41
 
@@ -42,15 +48,21 @@ module Quake
42
48
  render_without_lightmaps(surfaces_by_texture)
43
49
  end
44
50
 
45
- # Sky drawn before translucent water so translucent water can
46
- # alpha-blend over sky-tinted background. PVS-cull keeps distant
47
- # outdoor sky polys out, and TEXTURE_2D must stay enabled or sky
48
- # polys render as untextured white shapes.
51
+ # PVS-cull keeps distant outdoor sky polys out, and TEXTURE_2D must
52
+ # stay enabled or sky polys render as untextured white shapes.
49
53
  @sky&.render(camera, visible_face_set: @last_visible_face_set)
50
54
 
51
- # Translucent water last so it alpha-blends over everything else.
52
- @water&.render(alpha: 0.5)
55
+ GL.Disable(GL::TEXTURE_2D)
56
+ end
57
+
58
+ # Water is drawn after entities/viewmodel like R_DrawWaterSurfaces so
59
+ # translucent water blends over everything behind it.
60
+ # GLQuake's default r_wateralpha is 1.0.
61
+ def render_water(alpha: 1.0)
62
+ return unless @water
53
63
 
64
+ GL.Enable(GL::TEXTURE_2D)
65
+ @water.render(alpha: alpha)
54
66
  GL.Disable(GL::TEXTURE_2D)
55
67
  end
56
68
 
@@ -63,25 +75,15 @@ module Quake
63
75
 
64
76
  def visible_surfaces(camera)
65
77
  leaf_index = Bsp::Vis.point_in_leaf(@level, camera.position)
66
- leaf = @level.leafs[leaf_index]
67
- # FatPVS depends on point position, so recompute every frame when
68
- # the camera leaf touches a liquid surface. Otherwise the cheap
69
- # leaf-keyed cache is fine.
70
- near_liquid = Bsp::Vis.near_liquid_portal?(@level, leaf)
71
-
72
- if near_liquid
73
- compute_visible_surfaces(leaf_index, point: camera.position)
74
- else
75
- if leaf_index != @last_leaf
76
- @last_leaf = leaf_index
77
- @visible_surface_cache = compute_visible_surfaces(leaf_index)
78
- end
79
- @visible_surface_cache || @all_surfaces_by_texture
78
+ if leaf_index != @last_leaf
79
+ @last_leaf = leaf_index
80
+ @visible_surface_cache = compute_visible_surfaces(leaf_index)
80
81
  end
82
+ @visible_surface_cache || @all_surfaces_by_texture
81
83
  end
82
84
 
83
- def compute_visible_surfaces(leaf_index, point: nil)
84
- visible_face_set = Bsp::Vis.visible_faces(@level, leaf_index, point: point)
85
+ def compute_visible_surfaces(leaf_index)
86
+ visible_face_set = Bsp::Vis.visible_faces(@level, leaf_index)
85
87
  # Stash so the sky pass can cull against the same set.
86
88
  @last_visible_face_set = visible_face_set
87
89
 
@@ -102,7 +104,9 @@ module Quake
102
104
  GL.Color3f(1.0, 1.0, 1.0)
103
105
 
104
106
  surfaces_by_texture.each do |miptex_index, surfaces|
105
- @texture_manager.bind(miptex_index)
107
+ @texture_manager.bind(miptex_index, time: (@time || 0.0).to_f)
108
+ fence = @texture_manager.fence?(miptex_index)
109
+ enable_fence_alpha_test if fence
106
110
 
107
111
  surfaces.each do |surf|
108
112
  GL.Begin(GL::TRIANGLE_FAN)
@@ -113,6 +117,8 @@ module Quake
113
117
  end
114
118
  GL.End
115
119
  end
120
+
121
+ GL.Disable(GL::ALPHA_TEST) if fence
116
122
  end
117
123
  end
118
124
 
@@ -123,7 +129,9 @@ module Quake
123
129
  GL.Color3f(1.0, 1.0, 1.0)
124
130
 
125
131
  surfaces_by_texture.each do |miptex_index, surfaces|
126
- @texture_manager.bind(miptex_index)
132
+ @texture_manager.bind(miptex_index, time: (@time || 0.0).to_f)
133
+ fence = @texture_manager.fence?(miptex_index)
134
+ enable_fence_alpha_test if fence
127
135
 
128
136
  surfaces.each do |surf|
129
137
  GL.Begin(GL::TRIANGLE_FAN)
@@ -134,16 +142,22 @@ module Quake
134
142
  end
135
143
  GL.End
136
144
  end
145
+
146
+ GL.Disable(GL::ALPHA_TEST) if fence
137
147
  end
138
148
 
139
149
  # Pass 2: multiply lightmap on top
140
150
  GL.Enable(GL::BLEND)
141
- GL.BlendFunc(GL::ZERO, GL::SRC_COLOR) # dst = dst * src (multiply)
142
- GL.DepthFunc(GL::EQUAL) # only write where geometry already drawn
151
+ GL.BlendFunc(GL::SRC_ALPHA, GL::ONE_MINUS_SRC_ALPHA)
152
+ GL.DepthMask(GL::FALSE)
143
153
  GL.TexEnvi(GL::TEXTURE_ENV, GL::TEXTURE_ENV_MODE, GL::REPLACE)
144
154
 
145
155
  last_lm_tex = -1
146
- surfaces_by_texture.each do |_miptex_index, surfaces|
156
+ surfaces_by_texture.each do |miptex_index, surfaces|
157
+ # Fence holes carry no diffuse/depth from pass 1; the lightmap quad
158
+ # would darken whatever shows through, so fences skip the light pass.
159
+ next if @texture_manager.fence?(miptex_index)
160
+
147
161
  surfaces.each do |surf|
148
162
  face_idx = surf[:face_index]
149
163
  next unless face_idx && @lightmap.face_lightmaps[face_idx]
@@ -154,10 +168,13 @@ module Quake
154
168
  last_lm_tex = lm_info.gl_texture
155
169
  end
156
170
 
157
- texinfo = @level.texinfo[surf[:texinfo_index]]
171
+ lm_coords = surf[:lm_texcoords] ||= begin
172
+ texinfo = @level.texinfo[surf[:texinfo_index]]
173
+ surf[:vertices].map { |v| @lightmap.lightmap_texcoords(face_idx, v, texinfo) }
174
+ end
158
175
  GL.Begin(GL::TRIANGLE_FAN)
159
176
  surf[:vertices].each_with_index do |v, i|
160
- ls, lt = @lightmap.lightmap_texcoords(face_idx, v, texinfo)
177
+ ls, lt = lm_coords[i]
161
178
  GL.TexCoord2f(ls, lt)
162
179
  GL.Vertex3f(v.x, v.y, v.z)
163
180
  end
@@ -165,7 +182,7 @@ module Quake
165
182
  end
166
183
  end
167
184
 
168
- GL.DepthFunc(GL::LESS)
185
+ GL.DepthMask(GL::TRUE)
169
186
  GL.Disable(GL::BLEND)
170
187
  GL.TexEnvi(GL::TEXTURE_ENV, GL::TEXTURE_ENV_MODE, GL::MODULATE)
171
188
  end
@@ -210,6 +227,11 @@ module Quake
210
227
  grouped
211
228
  end
212
229
 
230
+ def enable_fence_alpha_test
231
+ GL.AlphaFunc(GL::GREATER, 0.666)
232
+ GL.Enable(GL::ALPHA_TEST)
233
+ end
234
+
213
235
  def build_face_index
214
236
  index = {}
215
237
  @all_surfaces_by_texture.each do |miptex_index, surfaces|
@@ -0,0 +1,130 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "opengl"
4
+ require_relative "../physics/hull_trace"
5
+
6
+ module Quake
7
+ module Renderer
8
+ # Renders Quake GL polyblend overlays from view.c color shifts.
9
+ class GLViewBlend
10
+ CShift = Data.define(:color, :percent)
11
+
12
+ EMPTY_CSHIFT = CShift.new(color: [130, 80, 50], percent: 0)
13
+ WATER_CSHIFT = CShift.new(color: [130, 80, 50], percent: 128)
14
+ SLIME_CSHIFT = CShift.new(color: [0, 25, 5], percent: 150)
15
+ LAVA_CSHIFT = CShift.new(color: [255, 80, 0], percent: 150)
16
+ BONUS_COLOR = [215, 186, 69].freeze
17
+ DAMAGE_ARMOR_COLOR = [200, 100, 100].freeze
18
+ DAMAGE_MIXED_COLOR = [220, 50, 50].freeze
19
+ DAMAGE_BLOOD_COLOR = [255, 0, 0].freeze
20
+
21
+ def self.contents_cshift(contents)
22
+ case contents
23
+ when Physics::CONTENTS_EMPTY, Physics::CONTENTS_SOLID
24
+ EMPTY_CSHIFT
25
+ when Physics::CONTENTS_LAVA
26
+ LAVA_CSHIFT
27
+ when Physics::CONTENTS_SLIME
28
+ SLIME_CSHIFT
29
+ else
30
+ WATER_CSHIFT
31
+ end
32
+ end
33
+
34
+ def self.powerup_cshift(player_state, game_time:)
35
+ if powerup_item_active?(player_state, :quad, game_time)
36
+ CShift.new(color: [0, 0, 255], percent: 30)
37
+ elsif powerup_item_active?(player_state, :biosuit, game_time)
38
+ CShift.new(color: [0, 255, 0], percent: 20)
39
+ elsif powerup_item_active?(player_state, :ring, game_time)
40
+ CShift.new(color: [100, 100, 100], percent: 100)
41
+ elsif powerup_item_active?(player_state, :pentagram, game_time)
42
+ CShift.new(color: [255, 255, 0], percent: 30)
43
+ else
44
+ CShift.new(color: [0, 0, 0], percent: 0)
45
+ end
46
+ end
47
+
48
+ def self.powerup_item_active?(player_state, powerup, game_time)
49
+ player_state&.powerup_item_active?(powerup, game_time: game_time)
50
+ end
51
+
52
+ def self.bonus_cshift(percent)
53
+ CShift.new(color: BONUS_COLOR, percent: percent.to_f.clamp(0.0, 50.0))
54
+ end
55
+
56
+ def self.damage_cshift(armor:, blood:, current_percent: 0.0)
57
+ armor = armor.to_f
58
+ blood = blood.to_f
59
+ count = blood * 0.5 + armor * 0.5
60
+ count = 10.0 if count < 10.0
61
+ percent = (current_percent.to_f + 3.0 * count).clamp(0.0, 150.0)
62
+ color = if armor > blood
63
+ DAMAGE_ARMOR_COLOR
64
+ elsif armor.positive?
65
+ DAMAGE_MIXED_COLOR
66
+ else
67
+ DAMAGE_BLOOD_COLOR
68
+ end
69
+ CShift.new(color: color, percent: percent)
70
+ end
71
+
72
+ def self.calc_blend(cshifts, cshift_percent: 100.0)
73
+ r = 0.0
74
+ g = 0.0
75
+ b = 0.0
76
+ a = 0.0
77
+
78
+ cshifts.each do |shift|
79
+ next if cshift_percent.zero?
80
+
81
+ a2 = ((shift.percent.to_f * cshift_percent.to_f) / 100.0) / 255.0
82
+ next if a2.zero?
83
+
84
+ a += a2 * (1.0 - a)
85
+ a2 /= a
86
+ r = r * (1.0 - a2) + shift.color[0] * a2
87
+ g = g * (1.0 - a2) + shift.color[1] * a2
88
+ b = b * (1.0 - a2) + shift.color[2] * a2
89
+ end
90
+
91
+ [r / 255.0, g / 255.0, b / 255.0, a.clamp(0.0, 1.0)]
92
+ end
93
+
94
+ def render(blend)
95
+ return if blend[3].to_f <= 0.0
96
+
97
+ GL.MatrixMode(GL::PROJECTION)
98
+ GL.PushMatrix
99
+ GL.LoadIdentity
100
+ GL.Ortho(0, 1, 0, 1, -1, 1)
101
+ GL.MatrixMode(GL::MODELVIEW)
102
+ GL.PushMatrix
103
+ GL.LoadIdentity
104
+
105
+ GL.Disable(GL::DEPTH_TEST)
106
+ GL.Disable(GL::CULL_FACE) # 2D pass; scene re-enables culling next frame
107
+ GL.Disable(GL::TEXTURE_2D)
108
+ GL.Enable(GL::BLEND)
109
+ GL.BlendFunc(GL::SRC_ALPHA, GL::ONE_MINUS_SRC_ALPHA)
110
+ GL.Color4f(*blend)
111
+ GL.Begin(GL::QUADS)
112
+ GL.Vertex2f(0.0, 0.0)
113
+ GL.Vertex2f(1.0, 0.0)
114
+ GL.Vertex2f(1.0, 1.0)
115
+ GL.Vertex2f(0.0, 1.0)
116
+ GL.End
117
+
118
+ GL.Color4f(1.0, 1.0, 1.0, 1.0)
119
+ GL.Disable(GL::BLEND)
120
+ GL.Enable(GL::TEXTURE_2D)
121
+ GL.Enable(GL::DEPTH_TEST)
122
+ GL.MatrixMode(GL::MODELVIEW)
123
+ GL.PopMatrix
124
+ GL.MatrixMode(GL::PROJECTION)
125
+ GL.PopMatrix
126
+ GL.MatrixMode(GL::MODELVIEW)
127
+ end
128
+ end
129
+ end
130
+ end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "opengl"
4
+ require_relative "../math/vec3"
4
5
 
5
6
  module Quake
6
7
  module Renderer
@@ -13,6 +14,7 @@ module Quake
13
14
  CL_BOB = 0.02 # cl_bob: amplitude multiplier
14
15
  CL_BOBCYCLE = 0.6 # cl_bobcycle: seconds per full bob cycle
15
16
  CL_BOBUP = 0.5 # cl_bobup: fraction of cycle spent going up
17
+ VIEWMODEL_Z_FUDGE = 2.0 # scr_viewsize 100 in Quake's V_CalcRefdef
16
18
 
17
19
  # Current bob value (applied to camera Z by the game loop)
18
20
  attr_reader :bob
@@ -25,11 +27,15 @@ module Quake
25
27
  @bob_speed = 0.0
26
28
  @bob = 0.0
27
29
  @current_model_path = nil
30
+ @animation_frames = []
31
+ @animation_time = 0.0
28
32
  end
29
33
 
30
34
  def set_weapon(model_path)
31
35
  return if model_path == @current_model_path
32
36
  @current_model_path = model_path
37
+ @animation_frames = []
38
+ @animation_time = 0.0
33
39
  load_weapon(model_path) unless @weapon_renderers.key?(model_path)
34
40
  end
35
41
 
@@ -37,9 +43,16 @@ module Quake
37
43
  @time += dt
38
44
  @bob_speed = speed
39
45
  @bob = calc_bob
46
+ @animation_time += dt if @animation_frames.any?
40
47
  end
41
48
 
42
- def render(camera, aspect)
49
+ def play_attack(frames)
50
+ @animation_frames = frames.to_a
51
+ @animation_time = 0.0
52
+ end
53
+
54
+ def render(camera, aspect, visible: true, ambient_light: 200.0, shade_light: 200.0)
55
+ return unless visible
43
56
  return unless @current_model_path
44
57
 
45
58
  gl_model = @weapon_renderers[@current_model_path]
@@ -47,25 +60,35 @@ module Quake
47
60
 
48
61
  # Compress depth range so weapon always appears in front of world
49
62
  GL.DepthRange(0.0, 0.3)
50
- GL.DepthFunc(GL::LEQUAL)
51
-
52
- # Weapon origin = camera position + forward*bob*0.4
53
- # Camera already has the vertical bob applied (in the game loop),
54
- # so only the forward push is needed here.
55
- pos = camera.position + camera.forward * (@bob * 0.4)
63
+ # Weapon origin = view origin + forward*bob*0.4 plus the default
64
+ # scr_viewsize 100 fudge from Quake's V_CalcRefdef. The camera
65
+ # position already carries the vertical bob, and in C the gun's
66
+ # own +bob cancels against the camera's, so none is added here.
67
+ # The fudge is applied along the view's up axis (not world Z as in
68
+ # the C source): a world-Z offset swings the gun down/away as the
69
+ # view pitches, while the reference's gun stays glued to the view.
70
+ pos = camera.position +
71
+ camera.forward * (@bob * 0.4) +
72
+ camera.up * VIEWMODEL_Z_FUDGE
56
73
 
57
74
  GL.Enable(GL::TEXTURE_2D)
58
75
 
59
76
  gl_model.render(
60
- frame_index: 0,
77
+ frame_index: current_frame,
61
78
  lerp: 0.0,
62
79
  position: pos,
80
+ # CalcGunAngle sets viewent pitch to -viewpitch and
81
+ # R_RotateForEntity negates it again, so the gun's world rotation
82
+ # is +viewpitch -- it pitches with the view.
63
83
  yaw: camera.yaw,
64
- pitch: camera.pitch
84
+ pitch: camera.pitch,
85
+ roll: camera.roll,
86
+ ambient_light: ambient_light,
87
+ shade_light: shade_light,
88
+ view_model: true
65
89
  )
66
90
 
67
91
  GL.DepthRange(0.0, 1.0)
68
- GL.DepthFunc(GL::LESS)
69
92
  end
70
93
 
71
94
  private
@@ -73,7 +96,6 @@ module Quake
73
96
  # Exact V_CalcBob from Quake's view.c
74
97
  def calc_bob
75
98
  speed = @bob_speed
76
- return 0.0 if speed < 1.0
77
99
 
78
100
  # Asymmetric cycle: spend cl_bobup fraction going up, rest going down
79
101
  cycle = @time - (@time / CL_BOBCYCLE).floor * CL_BOBCYCLE
@@ -91,6 +113,19 @@ module Quake
91
113
  bob.clamp(-7.0, 4.0)
92
114
  end
93
115
 
116
+ def current_frame
117
+ return 0 if @animation_frames.empty?
118
+
119
+ idx = (@animation_time * 10.0).to_i
120
+ if idx >= @animation_frames.size
121
+ @animation_frames = []
122
+ @animation_time = 0.0
123
+ return 0
124
+ end
125
+
126
+ @animation_frames[idx]
127
+ end
128
+
94
129
  def load_weapon(model_path)
95
130
  begin
96
131
  data = @pak.read(model_path)
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Quake
4
+ module Renderer
5
+ module GLWarpSubdivision
6
+ SUBDIVIDE_SIZE = 128.0
7
+
8
+ private
9
+
10
+ # Matches GLQuake's SubdividePolygon using default gl_subdivide_size.
11
+ def subdivide_polygon(verts)
12
+ result = []
13
+ subdivide_polygon_recursive(verts, result)
14
+ result
15
+ end
16
+
17
+ def subdivide_polygon_recursive(verts, result)
18
+ 3.times do |axis|
19
+ mins, maxs = polygon_axis_bounds(verts, axis)
20
+ mid = (mins + maxs) * 0.5
21
+ mid = SUBDIVIDE_SIZE * (mid / SUBDIVIDE_SIZE + 0.5).floor
22
+ next if maxs - mid < 8.0
23
+ next if mid - mins < 8.0
24
+
25
+ front, back = split_polygon_at_axis(verts, axis, mid)
26
+ subdivide_polygon_recursive(front, result)
27
+ subdivide_polygon_recursive(back, result)
28
+ return
29
+ end
30
+
31
+ result << verts
32
+ end
33
+
34
+ def polygon_axis_bounds(verts, axis)
35
+ mins = Float::INFINITY
36
+ maxs = -Float::INFINITY
37
+ verts.each do |v|
38
+ val = v.to_a[axis]
39
+ mins = val if val < mins
40
+ maxs = val if val > maxs
41
+ end
42
+ [mins, maxs]
43
+ end
44
+
45
+ def split_polygon_at_axis(verts, axis, mid)
46
+ front = []
47
+ back = []
48
+ distances = verts.map { |v| v.to_a[axis] - mid }
49
+
50
+ verts.each_with_index do |v, i|
51
+ v_next = verts[(i + 1) % verts.size]
52
+ d1 = distances[i]
53
+ d2 = distances[(i + 1) % verts.size]
54
+
55
+ front << v if d1 >= 0.0
56
+ back << v if d1 <= 0.0
57
+ next if d1.zero? || d2.zero?
58
+ next if d1.positive? == d2.positive?
59
+
60
+ frac = d1 / (d1 - d2)
61
+ mid_vertex = Quake::Math::Vec3.new(
62
+ v.x + frac * (v_next.x - v.x),
63
+ v.y + frac * (v_next.y - v.y),
64
+ v.z + frac * (v_next.z - v.z)
65
+ )
66
+ front << mid_vertex
67
+ back << mid_vertex
68
+ end
69
+
70
+ [front, back]
71
+ end
72
+ end
73
+ end
74
+ end
@@ -1,15 +1,16 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "opengl"
4
+ require_relative "gl_warp_subdivision"
4
5
 
5
6
  module Quake
6
7
  module Renderer
7
8
  # Renders turbulent water/lava/slime surfaces with sine-wave warping.
8
9
  # Quake liquid textures have names starting with '*'.
9
10
  class GLWater
10
- TURBSCALE = 256.0 / (2.0 * ::Math::PI) # ~40.74
11
+ include GLWarpSubdivision
11
12
 
12
- SUBDIVIDE_SIZE = 64.0
13
+ TURBSCALE = 256.0 / (2.0 * ::Math::PI) # ~40.74
13
14
 
14
15
  def initialize(level, texture_manager)
15
16
  @level = level
@@ -36,13 +37,6 @@ module Quake
36
37
  return if @water_surfaces.empty?
37
38
 
38
39
  GL.Enable(GL::TEXTURE_2D)
39
- # Quake BSP stores liquid surfaces as front/back face pairs on the
40
- # same plane so the surface is visible from above and below. Without
41
- # culling both copies rasterize at the same depth, z-fight, and (with
42
- # alpha < 1) double-blend, producing flicker. Match TyrQuake:
43
- # glCullFace(GL_FRONT) with default CCW front face.
44
- GL.Enable(GL::CULL_FACE)
45
- GL.CullFace(GL::FRONT)
46
40
 
47
41
  if alpha < 1.0
48
42
  GL.Enable(GL::BLEND)
@@ -75,8 +69,6 @@ module Quake
75
69
  end
76
70
  end
77
71
 
78
- GL.Disable(GL::CULL_FACE)
79
-
80
72
  if alpha < 1.0
81
73
  GL.Disable(GL::BLEND)
82
74
  GL.Color4f(1.0, 1.0, 1.0, 1.0)
@@ -115,7 +107,7 @@ module Quake
115
107
 
116
108
  subdivided.each do |poly_verts|
117
109
  texcoords = poly_verts.map do |v|
118
- # Raw dot product only, no offset (matches TyrQuake SubdividePolygon)
110
+ # Raw dot product only, no offset (matches Quake SubdividePolygon)
119
111
  s = v.dot(texinfo.s_vec)
120
112
  t = v.dot(texinfo.t_vec)
121
113
  [s, t]
@@ -131,70 +123,6 @@ module Quake
131
123
  puts "Found #{@water_surfaces.sum { |ws| ws[:faces].size }} water surfaces"
132
124
  end
133
125
 
134
- # Subdivide polygon along axial 64-unit boundaries for smoother warp.
135
- # Matches TyrQuake's SubdividePolygon.
136
- def subdivide_polygon(verts)
137
- polys = [verts]
138
-
139
- 3.times do |axis|
140
- next_polys = []
141
- polys.each do |poly|
142
- split_polygon_axis(poly, axis, next_polys)
143
- end
144
- polys = next_polys
145
- end
146
-
147
- polys
148
- end
149
-
150
- def split_polygon_axis(verts, axis, result)
151
- # Find bounds on this axis
152
- mins = Float::INFINITY
153
- maxs = -Float::INFINITY
154
- verts.each do |v|
155
- val = v.to_a[axis]
156
- mins = val if val < mins
157
- maxs = val if val > maxs
158
- end
159
-
160
- # If small enough, no split needed
161
- if maxs - mins <= SUBDIVIDE_SIZE
162
- result << verts
163
- return
164
- end
165
-
166
- # Find split plane (first 64-unit boundary)
167
- dist = ((mins + SUBDIVIDE_SIZE) / SUBDIVIDE_SIZE).floor * SUBDIVIDE_SIZE
168
- # Ensure we're actually splitting
169
- dist = mins + SUBDIVIDE_SIZE if dist <= mins
170
-
171
- front = []
172
- back = []
173
-
174
- verts.each_with_index do |v, i|
175
- v_next = verts[(i + 1) % verts.size]
176
- d1 = v.to_a[axis] - dist
177
- d2 = v_next.to_a[axis] - dist
178
-
179
- front << v if d1 >= 0
180
- back << v if d1 < 0
181
-
182
- # Edge crosses the split plane
183
- if (d1 >= 0) != (d2 >= 0)
184
- frac = d1 / (d1 - d2)
185
- mid = Quake::Math::Vec3.new(
186
- v.x + frac * (v_next.x - v.x),
187
- v.y + frac * (v_next.y - v.y),
188
- v.z + frac * (v_next.z - v.z)
189
- )
190
- front << mid
191
- back << mid
192
- end
193
- end
194
-
195
- split_polygon_axis(front, axis, result) if front.size >= 3
196
- split_polygon_axis(back, axis, result) if back.size >= 3
197
- end
198
126
  end
199
127
  end
200
128
  end