def palettize(self, palette_img, create_rgb=True): """ Runs all palettization procedures on the given PaletteImage. Creates an RGB version of the palette if requested. :palette_img: The PaletteImage you want to palettize. :create_rgb: Would you like to create an RGB variant of the palette? True by default. """ # We need to know the palette's prior size if not palette_img.size_known: raise PalettizerException( "Palette image's size is not known, this is a fatal error!") palette_name = palette_img.basename if self.debug: print( f'[{palette_img.page.group.dirname}] Compiling {palette_name}..' ) # Our max distortion is 1, as we do not want to resize the palette to be smaller accidentally max_distortion = 1 # This array holds all of our PNMImage source textures imgs = [] # We're going to check all of the source textures # so that we can load all of them and determine our palette's final size for placement in palette_img.placements: sources = placement.texture.sources # We need to know the size of all source textures # If a source texture's size is not known, that means that it has been defined but did not exist on the disk when creating textures.boo for source in sources: if not source.size_known: print( f'Warning: Size is not known for source {source.filename}, please consider fixing the texture path and re-running egg-palettize' ) # Source (original) full resolution texture size source = sources[0] x_size = source.x_size y_size = source.y_size # Prior texture position and size tex_position = placement.position tex_x_size = tex_position.x_size tex_y_size = tex_position.y_size # DISTORTER # Fold the points until they scream out # DISTORTER # Change the way it sounds # We need to calculate the maximum distortion for both our X and Y (U/V) coordinates if x_size != tex_x_size: x_distortion = x_size / tex_x_size if x_distortion > max_distortion: max_distortion = x_distortion if y_size != tex_y_size: y_distortion = y_size / tex_y_size if y_distortion > max_distortion: max_distortion = y_distortion # If we have more than one source, that means our texture # has been defined multiple times for the same group... # Panda3D uses the first source texture only, so that's what we'll do. # But we'll make sure to print a warning either way! if len(sources) > 1: source2 = sources[1] if source2.x_size != x_size or source2.y_size != y_size: print( f'Two different source textures are defined for {palette_name}: {source.filename} ({x_size} {y_size}) vs {source2.filename} ({source2.x_size} {source2.y_size})' ) # Time to load the source file from Pandora! img = self.read_texture(source.filename) # Add the source image to our list imgs.append(img) # Well, time to calculate the palette's final size! # We will multiply the current size with the highest distortion. # We do NOT calculate X and Y distortion separately. # Doing so would destroy the aspect ratio of the palette. current_x_size = palette_img.x_size current_y_size = palette_img.y_size new_x_size = round(current_x_size * max_distortion) new_y_size = round(current_y_size * max_distortion) # Power of two time! # It's easier for the game engine to load, as it does not have to scale it automatically. new_x_size, new_y_size = self.scale_power_of_2(new_x_size, new_y_size) # We've changed the palette size. It is necessary to recalculate our texture distortion. x_distortion = new_x_size / current_x_size y_distortion = new_y_size / current_y_size # Create our palette image with four channels. # We will cut down the last channel when necessary. # Having a fourth, empty channel would only increase the file size. new_image = PNMImage(new_x_size, new_y_size, 4) new_image.alpha_fill(1) # Textures with alpha always have four channels set (three for RGB and one for Alpha). has_alpha = palette_img.properties.effective_channels in (2, 4) rgb_only = palette_img.properties.format == TextureGlobals.F_alpha alpha_image = None # If necessary and possible, create an alpha image as well. # Textures with alpha always have four channels set (three for RGB and one for Alpha). if create_rgb and has_alpha and not rgb_only: alpha_image = PNMImage(new_x_size, new_y_size, 1) alpha_image.set_type(PalettizeGlobals.RGB_TYPE) for i, placement in enumerate(palette_img.placements): # Find the loaded source image from before... texture_img = imgs[i] # Calculate the placement of our image! tex_position = placement.placed # Determine the upper left and lower right corners # with some matrix magic. transform = placement.compute_tex_matrix() ul = transform.xform_point(LTexCoordd(0.0, 1.0)) lr = transform.xform_point(LTexCoordd(1.0, 0.0)) # Calculate the top, left, bottom and right corners. top = int(math.floor((1.0 - ul[1]) * new_y_size + 0.5)) left = int(math.floor(ul[0] * new_x_size + 0.5)) bottom = int(math.floor((1.0 - lr[1]) * new_y_size + 0.5)) right = int(math.floor(lr[0] * new_x_size + 0.5)) tex_x_size = right - left tex_y_size = bottom - top org_x_size = round(tex_position.x_size * x_distortion) org_y_size = round(tex_position.y_size * y_distortion) tex_x = round(tex_position.x * x_distortion) tex_y = round(tex_position.y * y_distortion) # Resize our image to the desired size. texture_img = self.resize_image(texture_img, tex_x_size, tex_y_size) for y in range(tex_y, tex_y + org_y_size): sy = y - top # UV wrapping modes - V component (for Y texture coordinate) if placement.placed.wrap_v == TextureGlobals.WM_clamp: sy = max(min(sy, tex_y_size - 1), 0) elif placement.placed.wrap_v == TextureGlobals.WM_mirror: sy = (tex_y_size * 2) - 1 - ( (-sy - 1) % (tex_y_size * 2)) if sy < 0 else sy % (tex_y_size * 2) sy = sy if sy < tex_y_size else 2 * tex_y_size - sy - 1 elif placement.placed.wrap_v == TextureGlobals.WM_mirror_once: sy = sy if sy < tex_y_size else 2 * tex_y_size - sy - 1 # Repeat texture sy = tex_y_size - 1 - ( (-sy - 1) % tex_y_size) if sy < 0 else sy % tex_y_size elif placement.placed.wrap_v == TextureGlobals.WM_border_color: if sy < 0 or sy >= tex_y_size: continue else: # Repeat texture sy = tex_y_size - 1 - ( (-sy - 1) % tex_y_size) if sy < 0 else sy % tex_y_size for x in range(tex_x, tex_x + org_x_size): sx = x - left # UV wrapping modes - U component (for X texture coordinate) if placement.placed.wrap_u == TextureGlobals.WM_clamp: sx = max(min(sx, tex_x_size - 1), 0) elif placement.placed.wrap_u == TextureGlobals.WM_mirror: sx = (tex_x_size * 2) - 1 - ( (-sx - 1) % (tex_x_size * 2)) if sx < 0 else sx % (tex_x_size * 2) sx = sx if sx < tex_x_size else 2 * tex_x_size - sx - 1 elif placement.placed.wrap_u == TextureGlobals.WM_mirror_once: sx = sx if sx >= 0 else ~sx # Repeat texture sx = tex_x_size - 1 - ( (-sx - 1) % tex_x_size) if sx < 0 else sx % tex_x_size elif placement.placed.wrap_u == TextureGlobals.WM_border_color: if sx < 0 or sx >= tex_x_size: continue else: # Repeat texture sx = tex_x_size - 1 - ( (-sx - 1) % tex_x_size) if sx < 0 else sx % tex_x_size new_image.set_xel(x, y, texture_img.get_xel(sx, sy)) new_image.set_alpha(x, y, texture_img.get_alpha(sx, sy)) if alpha_image: alpha_image.set_gray(x, y, texture_img.get_alpha(sx, sy)) return new_image, alpha_image, has_alpha, rgb_only
def convert_texture(self, texture, model_path=None): if not self.model_path: self.print_exc('ERROR: No model path specified in ImageConverter.') return tex_path = texture[0] tex_basename = os.path.splitext(os.path.basename(tex_path))[0] if not os.path.isabs(tex_path): if '../' in tex_path and model_path: # This texture path is using relative paths. # We assume that the working directory is the model's directory tex_path = os.path.join(os.path.dirname(model_path), tex_path) else: tex_path = os.path.join(self.model_path, tex_path) tex_path = tex_path.replace('\\', os.sep).replace('/', os.sep) if not os.path.exists(tex_path): self.print_exc('ERROR: Could not convert {}: Missing RGB texture!'.format(tex_path)) return png_tex_path = os.path.join(os.path.dirname(tex_path), tex_basename + '.png') png_tex_path = png_tex_path.replace('\\', os.sep).replace('/', os.sep) print('Converting to PNG...', png_tex_path) if len(texture) == 1: # Only one texture, we can save this immediately if tex_path.lower().endswith('.rgb'): output_img = PNMImage() output_img.read(Filename.from_os_specific(tex_path)) if output_img.num_channels in (1, 2) and 'golf_ball' not in tex_path and 'roll-o-dex' not in tex_path: # HACK: Toontown output_img.set_color_type(4) for i in range(output_img.get_x_size()): for j in range(output_img.get_y_size()): output_img.set_alpha(i, j, output_img.get_gray(i, j)) else: output_img = self.read_texture(tex_path, alpha=False) elif len(texture) == 2: img = self.read_texture(tex_path, alpha=True) # Two textures: the second one should be a RGB file alpha_path = texture[1] if not os.path.isabs(alpha_path): if '../' in alpha_path and model_path: # This texture path is using relative paths. # We assume that the working directory is the model's directory alpha_path = os.path.join(os.path.dirname(model_path), alpha_path) else: alpha_path = os.path.join(self.model_path, alpha_path) alpha_path = alpha_path.replace('\\', os.sep).replace('/', os.sep) if not os.path.exists(alpha_path): self.print_exc('ERROR: Could not convert {} with alpha {}: Missing alpha texture!'.format(tex_path, alpha_path)) return alpha_img = PNMImage() alpha_img.read(Filename.from_os_specific(alpha_path)) alpha_img = self.resize_image(alpha_img, img.get_x_size(), img.get_y_size()) output_img = PNMImage(img.get_x_size(), img.get_y_size(), 4) output_img.alpha_fill(1) output_img.copy_sub_image(img, 0, 0, 0, 0, img.get_x_size(), img.get_y_size()) for i in range(img.get_x_size()): for j in range(img.get_y_size()): output_img.set_alpha(i, j, alpha_img.get_gray(i, j)) output_img.write(Filename.from_os_specific(png_tex_path))
class GPUFFT: """ This is a collection of compute shaders to generate the inverse fft efficiently on the gpu, with butterfly FFT and precomputed weights """ def __init__(self, size, source_tex, normalization_factor): """ Creates a new fft instance. The source texture has to specified from the begining, as the shaderAttributes are pregenerated for performance reasons """ self.size = size self.log2_size = int(math.log(size, 2)) self.normalization_factor = normalization_factor # Create a ping and a pong texture, because we can't write to the # same texture while reading to it (that would lead to unexpected # behaviour, we could solve that by using an appropriate thread size, # but it works fine so far) self.ping_texture = Texture("FFTPing") self.ping_texture.setup_2d_texture(self.size, self.size, Texture.TFloat, Texture.FRgba32) self.pong_texture = Texture("FFTPong") self.pong_texture.setup_2d_texture(self.size, self.size, Texture.TFloat, Texture.FRgba32) self.source_tex = source_tex for tex in [self.ping_texture, self.pong_texture, source_tex]: tex.set_minfilter(Texture.FTNearest) tex.set_magfilter(Texture.FTNearest) tex.set_wrap_u(Texture.WMClamp) tex.set_wrap_v(Texture.WMClamp) # Pregenerate weights & indices for the shaders self._compute_weighting() # Pre generate the shaders, we have 2 passes: Horizontal and Vertical # which both execute log2(N) times with varying radii self.horizontal_fft_shader = Shader.load_compute( Shader.SLGLSL, "/$$rp/rpcore/water/shader/horizontal_fft.compute") self.horizontal_fft = NodePath("HorizontalFFT") self.horizontal_fft.set_shader(self.horizontal_fft_shader) self.horizontal_fft.set_shader_input("precomputedWeights", self.weights_lookup_tex) self.horizontal_fft.set_shader_input("N", LVecBase2i(self.size)) self.vertical_fft_shader = Shader.load_compute( Shader.SLGLSL, "/$$rp/rpcore/water/shader/vertical_fft.compute") self.vertical_fft = NodePath("VerticalFFT") self.vertical_fft.set_shader(self.vertical_fft_shader) self.vertical_fft.set_shader_input("precomputedWeights", self.weights_lookup_tex) self.vertical_fft.set_shader_input("N", LVecBase2i(self.size)) # Create a texture where the result is stored self.result_texture = Texture("Result") self.result_texture.setup2dTexture(self.size, self.size, Texture.TFloat, Texture.FRgba16) self.result_texture.set_minfilter(Texture.FTLinear) self.result_texture.set_magfilter(Texture.FTLinear) # Prepare the shader attributes, so we don't have to regenerate them # every frame -> That is VERY slow (3ms per fft instance) self._prepare_attributes() def get_result_texture(self): """ Returns the result texture, only contains valid data after execute was called at least once """ return self.result_texture def _generate_indices(self, storage_a, storage_b): """ This method generates the precompute indices, see http://cnx.org/content/m12012/latest/image1.png """ num_iter = self.size offset = 1 step = 0 for i in range(self.log2_size): num_iter = num_iter >> 1 step = offset for j in range(self.size): goLeft = (j // step) % 2 == 1 index_a, index_b = 0, 0 if goLeft: index_a, index_b = j - step, j else: index_a, index_b = j, j + step storage_a[i][j] = index_a storage_b[i][j] = index_b offset = offset << 1 def _generate_weights(self, storage): """ This method generates the precomputed weights """ # Using a custom pi variable should force the calculations to use # high precision (I hope so) pi = 3.141592653589793238462643383 num_iter = self.size // 2 num_k = 1 resolution_float = float(self.size) for i in range(self.log2_size): start = 0 end = 2 * num_k for b in range(num_iter): K = 0 for k in range(start, end, 2): fK = float(K) f_num_iter = float(num_iter) weight_a = Vec2( math.cos(2.0 * pi * fK * f_num_iter / resolution_float), -math.sin( 2.0 * pi * fK * f_num_iter / resolution_float)) weight_b = Vec2( -math.cos( 2.0 * pi * fK * f_num_iter / resolution_float), math.sin(2.0 * pi * fK * f_num_iter / resolution_float)) storage[i][k // 2] = weight_a storage[i][k // 2 + num_k] = weight_b K += 1 start += 4 * num_k end = start + 2 * num_k num_iter = num_iter >> 1 num_k = num_k << 1 def _reverse_row(self, indices): """ Reverses the bits in the given row. This is required for inverse fft (actually we perform a normal fft, but reversing the bits gives us an inverse fft) """ mask = 0x1 for j in range(self.size): val = 0x0 temp = int(indices[j]) # Int is required, for making a copy for i in range(self.log2_size): t = mask & temp val = (val << 1) | t temp = temp >> 1 indices[j] = val def _compute_weighting(self): """ Precomputes the weights & indices, and stores them in a texture """ indices_a = [[0 for i in range(self.size)] for k in range(self.log2_size)] indices_b = [[0 for i in range(self.size)] for k in range(self.log2_size)] weights = [[Vec2(0.0) for i in range(self.size)] for k in range(self.log2_size)] # Pre-Generating indices .. self._generate_indices(indices_a, indices_b) self._reverse_row(indices_a[0]) self._reverse_row(indices_b[0]) # Pre-Generating weights .." self._generate_weights(weights) # Create storage for the weights & indices self.weights_lookup = PNMImage(self.size, self.log2_size, 4) self.weights_lookup.setMaxval((2**16) - 1) self.weights_lookup.fill(0.0) # Populate storage for x in range(self.size): for y in range(self.log2_size): index_a = indices_a[y][x] index_b = indices_b[y][x] weight = weights[y][x] self.weights_lookup.set_red(x, y, index_a / float(self.size)) self.weights_lookup.set_green(x, y, index_b / float(self.size)) self.weights_lookup.set_blue(x, y, weight.x * 0.5 + 0.5) self.weights_lookup.set_alpha(x, y, weight.y * 0.5 + 0.5) # Convert storage to texture so we can use it in a shader self.weights_lookup_tex = Texture("Weights Lookup") self.weights_lookup_tex.load(self.weights_lookup) self.weights_lookup_tex.set_format(Texture.FRgba16) self.weights_lookup_tex.set_minfilter(Texture.FTNearest) self.weights_lookup_tex.set_magfilter(Texture.FTNearest) self.weights_lookup_tex.set_wrap_u(Texture.WMClamp) self.weights_lookup_tex.set_wrap_v(Texture.WMClamp) def _prepare_attributes(self): """ Prepares all shaderAttributes, so that we have a list of ShaderAttributes we can simply walk through in the update method, that is MUCH faster than using set_shader_input, as each call to set_shader_input forces the generation of a new ShaderAttrib """ self.attributes = [] textures = [self.ping_texture, self.pong_texture] current_index = 0 firstPass = True # Horizontal for step in range(self.log2_size): source = textures[current_index] dest = textures[1 - current_index] if firstPass: source = self.source_tex firstPass = False index = self.log2_size - step - 1 self.horizontal_fft.set_shader_input("source", source) self.horizontal_fft.set_shader_input("dest", dest) self.horizontal_fft.set_shader_input("butterflyIndex", LVecBase2i(index)) self._queue_shader(self.horizontal_fft) current_index = 1 - current_index # Vertical for step in range(self.log2_size): source = textures[current_index] dest = textures[1 - current_index] is_last_pass = step == self.log2_size - 1 if is_last_pass: dest = self.result_texture index = self.log2_size - step - 1 self.vertical_fft.set_shader_input("source", source) self.vertical_fft.set_shader_input("dest", dest) self.vertical_fft.set_shader_input("isLastPass", is_last_pass) self.vertical_fft.set_shader_input("normalizationFactor", self.normalization_factor) self.vertical_fft.set_shader_input("butterflyIndex", LVecBase2i(index)) self._queue_shader(self.vertical_fft) current_index = 1 - current_index def execute(self): """ Executes the inverse fft once """ for attr in self.attributes: self._execute_shader(attr) def _queue_shader(self, node): """ Internal method to fetch the ShaderAttrib of a node and store it in the update queue """ sattr = node.getAttrib(ShaderAttrib) self.attributes.append(sattr) def _execute_shader(self, sattr): """ Internal method to execute a shader by a given ShaderAttrib """ Globals.base.graphicsEngine.dispatch_compute( (self.size // 16, self.size // 16, 1), sattr, Globals.base.win.get_gsg())