@@ -319,6 +319,23 @@ def build(self, height_func):
319319 wz = self .world_z + iz * self .step
320320 self .heights [ix , iz ] = height_func (wx , wz )
321321 self .is_valid = True
322+
323+ def build_batch (self , height_func_batch ):
324+ """Vectorised equivalent of build(): evaluate the whole grid in one
325+ batched call instead of resolution*resolution scalar Python calls.
326+ The scalar path evaluates a few fbm/ridge octaves per point, so the
327+ pure-Python double loop dominates chunk upload time and stalls the
328+ render thread; the batched noise path is 1-2 orders of magnitude
329+ faster for the same result."""
330+ ix = np .arange (self .resolution , dtype = np .float32 )
331+ iz = np .arange (self .resolution , dtype = np .float32 )
332+ ixg , izg = np .meshgrid (ix , iz , indexing = 'ij' )
333+ wx = (self .world_x + ixg * self .step ).ravel ()
334+ wz = (self .world_z + izg * self .step ).ravel ()
335+ heights = height_func_batch (wx , wz )
336+ self .heights = np .asarray (heights , dtype = np .float32 ).reshape (
337+ (self .resolution , self .resolution ))
338+ self .is_valid = True
322339
323340 def get_height (self , world_x : float , world_z : float ) -> Optional [float ]:
324341 if not self .is_valid or self .heights is None :
@@ -417,6 +434,7 @@ def __init__(self, texture_manager=None, seed: int = 42):
417434 self .rock_tex = 0
418435 self .sand_tex = 0
419436 self .snow_tex = 0
437+ self ._placeholder_cubemap = 0
420438 if texture_manager :
421439 self .load_terrain_textures (texture_manager )
422440 self ._init_shader ()
@@ -751,7 +769,7 @@ def _generate_chunk_mesh(self, chunk: TerrainChunk, resolution: int) -> np.ndarr
751769
752770 def _upload_chunk (self , chunk : TerrainChunk , resolution : int ):
753771 if chunk .height_cache and not chunk .height_cache .is_valid :
754- chunk .height_cache .build (self ._get_height_scalar )
772+ chunk .height_cache .build_batch (self ._get_heights_batch )
755773 vertex_data = self ._generate_chunk_mesh (chunk , resolution )
756774 chunk .vertex_count = len (vertex_data ) // 14
757775 if not chunk .vao :
@@ -795,18 +813,55 @@ def _is_chunk_visible(self, chunk: TerrainChunk, frustum_planes) -> bool:
795813 if a * px + b * py + c * pz + d < 0 : return False
796814 return True
797815
816+ def _ensure_placeholder_cubemap (self ) -> int :
817+ """Lazily create a 1x1 complete cube-map used for shadow sampler units
818+ that have no real depth cube-map (shadows off, or fewer cube-maps than
819+ sampler slots). A complete texture on every unit guarantees no
820+ samplerCube is left referencing texture unit 0 or an incomplete texture,
821+ either of which can make glDrawArrays raise GL_INVALID_OPERATION."""
822+ if self ._placeholder_cubemap :
823+ return self ._placeholder_cubemap
824+ tex = int (gl .glGenTextures (1 ))
825+ gl .glBindTexture (gl .GL_TEXTURE_CUBE_MAP , tex )
826+ black = (ctypes .c_ubyte * 4 )(0 , 0 , 0 , 255 )
827+ for face in range (6 ):
828+ gl .glTexImage2D (gl .GL_TEXTURE_CUBE_MAP_POSITIVE_X + face , 0 , gl .GL_RGBA ,
829+ 1 , 1 , 0 , gl .GL_RGBA , gl .GL_UNSIGNED_BYTE , black )
830+ gl .glTexParameteri (gl .GL_TEXTURE_CUBE_MAP , gl .GL_TEXTURE_MIN_FILTER , gl .GL_NEAREST )
831+ gl .glTexParameteri (gl .GL_TEXTURE_CUBE_MAP , gl .GL_TEXTURE_MAG_FILTER , gl .GL_NEAREST )
832+ gl .glTexParameteri (gl .GL_TEXTURE_CUBE_MAP , gl .GL_TEXTURE_WRAP_S , gl .GL_CLAMP_TO_EDGE )
833+ gl .glTexParameteri (gl .GL_TEXTURE_CUBE_MAP , gl .GL_TEXTURE_WRAP_T , gl .GL_CLAMP_TO_EDGE )
834+ gl .glTexParameteri (gl .GL_TEXTURE_CUBE_MAP , gl .GL_TEXTURE_WRAP_R , gl .GL_CLAMP_TO_EDGE )
835+ gl .glBindTexture (gl .GL_TEXTURE_CUBE_MAP , 0 )
836+ self ._placeholder_cubemap = tex
837+ return self ._placeholder_cubemap
838+
798839 def update_and_render (self , projection : glm .mat4 , view : glm .mat4 , camera_pos : glm .vec3 , frustum_planes = None , lights = None , active_lights_count = 0 ,
799840 shadow_cubemaps = None , shadow_index_map = None , shadow_unit_base = 4 ):
800841 if not self .enabled : return
801842 if not self .shader_program :
802843 self ._init_shader ()
803844 if not self .shader_program : return
804845
805- # Ensure late-bound uniforms exist (can happen if shader was set externally)
806- if 'use_textures' not in self .uniforms :
807- self .uniforms ['use_textures' ] = gl .glGetUniformLocation (self .shader_program , 'use_textures' )
808- if 'lod_level' not in self .uniforms :
809- self .uniforms ['lod_level' ] = gl .glGetUniformLocation (self .shader_program , 'lod_level' )
846+ # Re-resolve late-bound uniforms against the *current* program. The
847+ # renderer can swap in an externally-compiled program whose uniform
848+ # table only covers a subset of locations (see
849+ # Renderer.setup_terrain_shader), so any sampler we rely on must be
850+ # looked up here or it stays unbound. An unassigned ``samplerCube``
851+ # defaults to texture unit 0, collides with the ``sampler2D`` terrain
852+ # textures bound there, and makes glDrawArrays raise
853+ # GL_INVALID_OPERATION.
854+ for name in ('use_textures' , 'lod_level' ):
855+ if name not in self .uniforms :
856+ self .uniforms [name ] = gl .glGetUniformLocation (self .shader_program , name )
857+ for i in range (8 ):
858+ key = f'lights[{ i } ].shadowIndex'
859+ if key not in self .uniforms :
860+ self .uniforms [key ] = gl .glGetUniformLocation (self .shader_program , key )
861+ for i in range (shaders .MAX_SHADOW_LIGHTS ):
862+ key = f'shadowMaps[{ i } ]'
863+ if key not in self .uniforms :
864+ self .uniforms [key ] = gl .glGetUniformLocation (self .shader_program , key )
810865
811866 self .visible_chunks = 0
812867 self .culled_chunks = 0
@@ -846,14 +901,30 @@ def update_and_render(self, projection: glm.mat4, view: glm.mat4, camera_pos: gl
846901 gl .glUniform1i (sidx_loc , shadow_index_map .get (id (light ), - 1 ))
847902
848903 # Bind depth cube-maps so terrain receives point-light shadows.
849- if shadow_cubemaps :
850- for i , cm in enumerate (shadow_cubemaps ):
851- loc = self .uniforms .get (f'shadowMaps[{ i } ]' , - 1 )
852- if loc is not None and loc != - 1 :
853- gl .glActiveTexture (gl .GL_TEXTURE0 + shadow_unit_base + i )
854- gl .glBindTexture (gl .GL_TEXTURE_CUBE_MAP , cm )
855- gl .glUniform1i (loc , shadow_unit_base + i )
856- gl .glActiveTexture (gl .GL_TEXTURE0 )
904+ #
905+ # Every ``samplerCube shadowMaps[i]`` uniform must be pointed at its own
906+ # reserved texture unit *unconditionally*. GLSL samplers default to
907+ # texture unit 0, which already holds a ``sampler2D`` (texGrass). The
908+ # spec forbids two different sampler types referencing the same texture
909+ # image unit, so leaving the cube samplers on unit 0 makes the driver
910+ # raise GL_INVALID_OPERATION on the very next draw call. We therefore
911+ # always assign the units and bind a *complete* cube-map to each — the
912+ # real depth cube-map when available, otherwise a 1x1 placeholder — even
913+ # when shadows are disabled or fewer cube-maps than sampler slots are
914+ # supplied. Binding the incomplete default texture (name 0) would leave
915+ # an active samplerCube pointing at an incomplete texture, which some
916+ # drivers also reject at draw time.
917+ shadow_cubemaps = shadow_cubemaps or []
918+ placeholder = self ._ensure_placeholder_cubemap ()
919+ for i in range (shaders .MAX_SHADOW_LIGHTS ):
920+ loc = self .uniforms .get (f'shadowMaps[{ i } ]' , - 1 )
921+ if loc is None or loc == - 1 :
922+ continue
923+ cm = shadow_cubemaps [i ] if (i < len (shadow_cubemaps ) and shadow_cubemaps [i ]) else placeholder
924+ gl .glActiveTexture (gl .GL_TEXTURE0 + shadow_unit_base + i )
925+ gl .glBindTexture (gl .GL_TEXTURE_CUBE_MAP , cm )
926+ gl .glUniform1i (loc , shadow_unit_base + i )
927+ gl .glActiveTexture (gl .GL_TEXTURE0 )
857928
858929 lod_level_loc = self .uniforms .get ('lod_level' , - 1 )
859930
0 commit comments