diff --git a/bemani/format/afp/render.py b/bemani/format/afp/render.py index f035392..56af688 100644 --- a/bemani/format/afp/render.py +++ b/bemani/format/afp/render.py @@ -261,18 +261,15 @@ class AFPRenderer(VerboseOutput): # Double check supported options. if tag.mult_color or tag.add_color: + # TODO: Handle additive and multiplicative color. print(f"WARNING: Unhandled color blend request Mult: {tag.mult_color} Add: {tag.add_color}!") # Look up the affine transformation matrix and rotation/origin. - transform = tag.transform or Matrix.identity() - origin = tag.rotation_offset or Point.identity() + transform = parent_transform.multiply(tag.transform or Matrix.identity()) + origin = parent_origin.add(tag.rotation_offset or Point.identity()) - # TODO: Need to do actual affine transformations here. - if transform.b != 0.0 or transform.c != 0.0 or transform.a != 1.0 or transform.d != 1.0: - print("WARNING: Unhandled affine transformation request!") - if parent_transform.b != 0.0 or parent_transform.c != 0.0 or parent_transform.a != 1.0 or parent_transform.d != 1.0: - print("WARNING: Unhandled affine transformation request!") - offset = parent_transform.multiply_point(transform.multiply_point(Point.identity().subtract(origin).subtract(parent_origin))) + # Calculate the inverse so we can map canvas space back to texture space. + inverse = transform.inverse() # Look up source shape. if tag.source_tag_id not in self.__registered_shapes: @@ -310,18 +307,60 @@ class AFPRenderer(VerboseOutput): if texture is not None: # Now, render out the texture. - cutin = Point(offset.x, offset.y) - cutoff = Point.identity() - if cutin.x < 0: - cutoff.x = -cutin.x - cutin.x = 0 - if cutin.y < 0: - cutoff.y = -cutin.y - cutin.y = 0 + imgmap = list(img.getdata()) + texmap = list(texture.getdata()) + + # Calculate the maximum range of update this texture can possibly reside in. + pix1 = transform.multiply_point(Point.identity().subtract(origin)) + pix2 = transform.multiply_point(Point.identity().subtract(origin).add(Point(texture.width, 0))) + pix3 = transform.multiply_point(Point.identity().subtract(origin).add(Point(0, texture.height))) + pix4 = transform.multiply_point(Point.identity().subtract(origin).add(Point(texture.width, texture.height))) + + # Map this to the rectangle we need to sweep in the rendering image. + minx = max(int(min(pix1.x, pix2.x, pix3.x, pix4.x)), 0) + maxx = min(int(max(pix1.x, pix2.x, pix3.x, pix4.x)) + 1, img.width) + miny = max(int(min(pix1.y, pix2.y, pix3.y, pix4.y)), 0) + maxy = min(int(max(pix1.y, pix2.y, pix3.y, pix4.y)) + 1, img.height) + + for imgy in range(miny, maxy): + for imgx in range(minx, maxx): + # Determine offset + imgoff = imgx + (imgy * img.width) + + # Calculate what texture pixel data goes here. + texloc = inverse.multiply_point(Point(float(imgx), float(imgy))).add(origin) + texx, texy = texloc.as_tuple() + + # If we're out of bounds, don't update. + if texx < 0 or texy < 0 or texx >= texture.width or texy >= texture.height: + continue + + # Blend it. + texoff = texx + (texy * texture.width) + imgmap[imgoff] = self.__blend(imgmap[imgoff], texmap[texoff]) + + img = Image.new("RGBA", (img.width, img.height)) + img.putdata(imgmap) - img.alpha_composite(texture, cutin.as_tuple(), cutoff.as_tuple()) return img + def __blend(self, bg: Tuple[int, int, int, int], fg: Tuple[int, int, int, int]) -> Tuple[int, int, int, int]: + # Short circuit for speed. + if fg[3] == 0: + return bg + if fg[3] == 255: + return fg + + # Calculate alpha blending. + fgpercent = (float(fg[3]) / 255.0) + bgpercent = 1.0 - fgpercent + return ( + max(int(float(bg[0]) * bgpercent + float(fg[0]) * fgpercent), 255), + max(int(float(bg[1]) * bgpercent + float(fg[1]) * fgpercent), 255), + max(int(float(bg[2]) * bgpercent + float(fg[2]) * fgpercent), 255), + 255, + ) + def __render(self, swf: SWF, export_tag: Optional[str]) -> Tuple[int, List[Image.Image]]: # If we are rendering an exported tag, we want to perform the actions of the # rest of the SWF but not update any layers as a result. @@ -349,10 +388,10 @@ class AFPRenderer(VerboseOutput): # Create a new image to render into. time = spf * float(frameno) color = swf.color or Color(0.0, 0.0, 0.0, 0.0) - curimage = Image.new("RGBA", (swf.location.width, swf.location.height), color=color.as_tuple()) self.vprint(f"Rendering Frame {frameno} ({time}s)") # Go through all registered clips, place all needed tags. + changed = False while any(c.dirty for c in self.__clips): newclips: List[Clip] = [] for clip in self.__clips: @@ -361,6 +400,7 @@ class AFPRenderer(VerboseOutput): self.vprint(f" Sprite Tag ID: {clip.tag_id}, Current Frame: {clip.frame.start_tag_offset + clip.frame.current_tag}, Num Frames: {clip.frame.num_tags}") newclips.extend(self.__place(clip.tags[clip.frame.start_tag_offset + clip.frame.current_tag], parent_clip=clip.tag_id)) clip.frame.current_tag += 1 + changed = True if clip.dirty and clip.frame.current_tag == clip.frame.num_tags: # We handled this clip. @@ -369,15 +409,21 @@ class AFPRenderer(VerboseOutput): # Add any new clips that we should process next frame. self.__clips.extend(newclips) - # Now, render out the placed objects. We sort by depth so that we can - # get the layering correct, but its important to preserve the original - # insertion order for delete requests. - for obj in sorted(self.__placed_objects, key=lambda obj: obj.depth): - if self.__visible_tag != obj.parent_clip: - continue + if changed or frameno == 0: + # Now, render out the placed objects. We sort by depth so that we can + # get the layering correct, but its important to preserve the original + # insertion order for delete requests. + curimage = Image.new("RGBA", (swf.location.width, swf.location.height), color=color.as_tuple()) + for obj in sorted(self.__placed_objects, key=lambda obj: obj.depth): + if self.__visible_tag != obj.parent_clip: + continue - self.vprint(f" Rendering placed object ID {obj.object_id} from sprite {obj.parent_clip} onto Depth {obj.depth}") - curimage = self.__render_object(curimage, obj.tag, Matrix.identity(), Point.identity()) + self.vprint(f" Rendering placed object ID {obj.object_id} from sprite {obj.parent_clip} onto Depth {obj.depth}") + curimage = self.__render_object(curimage, obj.tag, Matrix.identity(), Point.identity()) + else: + # Nothing changed, make a copy of the previous render. + self.vprint(" Using previous frame render") + curimage = frames[-1].copy() # Advance all the clips and frame now that we processed and rendered them. for clip in self.__clips: diff --git a/bemani/format/afp/types/generic.py b/bemani/format/afp/types/generic.py index c56a6bb..01c3d9a 100644 --- a/bemani/format/afp/types/generic.py +++ b/bemani/format/afp/types/generic.py @@ -47,14 +47,14 @@ class Point: return (int(self.x), int(self.y)) def add(self, other: "Point") -> "Point": - self.x += other.x - self.y += other.y - return self + x = self.x + other.x + y = self.y + other.y + return Point(x, y) def subtract(self, other: "Point") -> "Point": - self.x -= other.x - self.y -= other.y - return self + x = self.x - other.x + y = self.y - other.y + return Point(x, y) def __repr__(self) -> str: return f"x: {round(self.x, 5)}, y: {round(self.y, 5)}" @@ -110,5 +110,27 @@ class Matrix: y=(self.b * point.x) + (self.d * point.y) + self.ty, ) + def multiply(self, other: "Matrix") -> "Matrix": + return Matrix( + a=self.a * other.a + self.b * other.c, + b=self.a * other.b + self.b * other.d, + c=self.c * other.a + self.d * other.c, + d=self.c * other.b + self.d * other.d, + tx=self.tx * other.a + self.ty * other.c + other.tx, + ty=self.tx * other.b + self.ty * other.d + other.ty, + ) + + def inverse(self) -> "Matrix": + denom = (self.a * self.d - self.b * self.c) + + return Matrix( + a=self.d / denom, + b=-self.b / denom, + c=-self.c / denom, + d=self.a / denom, + tx=(self.c * self.ty - self.d * self.tx) / denom, + ty=-(self.a * self.ty - self.b * self.tx) / denom, + ) + def __repr__(self) -> str: return f"a: {round(self.a, 5)}, b: {round(self.b, 5)}, c: {round(self.c, 5)}, d: {round(self.d, 5)}, tx: {round(self.tx, 5)}, ty: {round(self.ty, 5)}"