/**
* Copyright 2014-2024 JogAmp Community. All rights reserved.
*
* Redistribution and use in source and binary forms, with or without modification, are
* permitted provided that the following conditions are met:
*
* 1. Redistributions of source code must retain the above copyright notice, this list of
* conditions and the following disclaimer.
*
* 2. Redistributions in binary form must reproduce the above copyright notice, this list
* of conditions and the following disclaimer in the documentation and/or other materials
* provided with the distribution.
*
* THIS SOFTWARE IS PROVIDED BY JogAmp Community ``AS IS'' AND ANY EXPRESS OR IMPLIED
* WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND
* FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL JogAmp Community OR
* CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
* CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
* SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
* ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
* NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF
* ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*
* The views and conclusions contained in the software and documentation are those of the
* authors and should not be interpreted as representing official policies, either expressed
* or implied, of JogAmp Community.
*/
package com.jogamp.graph.ui.shapes;
import com.jogamp.opengl.GL;
import com.jogamp.opengl.GL2ES2;
import com.jogamp.opengl.GLProfile;
import java.util.ArrayList;
import java.util.List;
import com.jogamp.common.av.AudioSink;
import com.jogamp.common.util.InterruptSource;
import com.jogamp.common.util.StringUtil;
import com.jogamp.graph.curve.Region;
import com.jogamp.graph.curve.opengl.RegionRenderer;
import com.jogamp.graph.font.Font;
import com.jogamp.graph.font.FontFactory;
import com.jogamp.graph.ui.GraphShape;
import com.jogamp.graph.ui.layout.Alignment;
import com.jogamp.math.Vec2f;
import com.jogamp.math.Vec4f;
import com.jogamp.math.geom.AABBox;
import com.jogamp.math.util.PMVMatrix4f;
import com.jogamp.opengl.util.av.SubTextEvent;
import com.jogamp.opengl.util.av.SubBitmapEvent;
import com.jogamp.opengl.util.av.SubtitleEvent;
import com.jogamp.opengl.util.av.SubtitleEventListener;
import com.jogamp.opengl.util.texture.ImageSequence;
import com.jogamp.opengl.util.av.CodecID;
import com.jogamp.opengl.util.av.GLMediaPlayer;
import com.jogamp.opengl.util.av.GLMediaPlayer.GLMediaEventListener;
import com.jogamp.opengl.util.av.GLMediaPlayer.StreamException;
/**
* A GraphUI {@link GLMediaPlayer} based {@link TexSeqButton} {@link GraphShape}.
*
* GraphUI is GPU based and resolution independent.
*
*
* This button is rendered with a round oval shape.
* To render it rectangular, {@link #setCorner(float)} to zero.
*
*
* Default colors (toggle-on is full color):
* - non-toggle: 1 * color
* - pressed: 0.9 * color
* - toggle-off: 0.8 * color
* - toggle-on: 1.0 * color
*
*/
public class MediaButton extends TexSeqButton {
private static final boolean DEBUG_SUB = false;
private static final boolean DEBUG_SUB_LAYOUT = false;
private boolean verbose = false;
private Font subFont;
private Font subFallbackFont;
private final Label subLabel;
private final float subZOffset;
private boolean subEnabled;
private float subLineHeightPct;
private float subLineDY;
private Alignment subAlignment;
private final Rectangle subBlend;
private final ImageButton subTexImg;
/** Default text/ASS subtitle line height percentage, {@value}. */
public static final float DEFAULT_ASS_SUB_HEIGHT = 0.075f;
/** Default text/ASS subtitle y-axis offset to bottom in line-height percentage, {@value}. */
public static final float DEFAULT_ASS_SUB_POS = 0.25f;
/** Default color for the text/ASS subtitles, defaults to RGBA {@code 1, 1, 1, 1}. */
public static final Vec4f DEFAULT_ASS_SUB_COLOR = new Vec4f( 1, 1, 1, 1 );
/** Default blending alpha (darkness) for the text/ASS subtitles, defaults to {@value}. */
public static final float DEFAULT_ASS_SUB_BLEND = 0.3f;
/** Default text/ASS subtitle alignment, defaults to {@link Alignment#CenterHoriz}. */
public static final Alignment DEFAULT_ASS_SUB_ALIGNMENT = Alignment.CenterHoriz;
private static final float ASS_SUB_USED_WIDTH = 0.90f;
private static final float ASS_SUB_BLEND_ADDED_HEIGHT = 0.25f;
private static final int ASS_SUB_MAX_SPLIT_LINES = 4;
private SubtitleEvent drawLastSub;
private final List subEventQueue = new ArrayList();
private final Object subEventLock = new Object();
/** Constructs a {@link MediaButton} with {@link FontFactory#getDefaultFont()} for subtitles. */
public MediaButton(final int renderModes, final float width, final float height, final GLMediaPlayer mPlayer) {
this(renderModes, width, height, mPlayer, null);
}
/**
* Constructs a {@link MediaButton} prepared for using subtitles
* @param renderModes
* @param width
* @param height
* @param mPlayer
* @param subFont text/ASS subtitle font, pass {@code null} for {@link FontFactory#getDefaultFont()}.
* {@link FontFactory#getFallbackFont()} is used {@link Font#getBestCoverage(Font, Font, CharSequence) if providing a better coverage} of a Text/ASS subtitle line.
* @see #setSubtitleParams(Font, float, float, Alignment)
* @see #setSubtitleColor(Vec4f, float)
*/
public MediaButton(final int renderModes, final float width, final float height, final GLMediaPlayer mPlayer, final Font subFont)
{
super(renderModes & ~Region.AA_RENDERING_MASK, width, height, mPlayer);
setColor(1.0f, 1.0f, 1.0f, 0.0f);
setPressedColorMod(0.9f, 0.9f, 0.9f, 0.7f);
setToggleOffColorMod(0.8f, 0.8f, 0.8f, 1.0f);
setToggleOnColorMod(1.0f, 1.0f, 1.0f, 1.0f);
mPlayer.setSubtitleEventListener(subEventListener);
setSubtitleParams(subFont, DEFAULT_ASS_SUB_HEIGHT, DEFAULT_ASS_SUB_POS, DEFAULT_ASS_SUB_ALIGNMENT);
this.subLabel = new Label(renderModes, this.subFont, "");
this.subZOffset = Button.DEFAULT_LABEL_ZOFFSET;
this.subLabel.moveTo(0, 0, subZOffset);
this.subBlend = new Rectangle(renderModes, 1f, 1f, 0f);
setSubtitleColor(DEFAULT_ASS_SUB_COLOR, DEFAULT_ASS_SUB_BLEND);
this.subTexImg = new ImageButton(renderModes, width, height, new ImageSequence(mPlayer.getTextureUnit(), true /* useBuildInTexLookup */));
this.subTexImg.setPerp().setToggleable(false).setDragAndResizable(false).setInteractive(false);
// this.subTexImg.setBorder(0.001f).setBorderColor(1, 1, 0, 1);
this.subTexImg.getImageSequence().setParams(GL.GL_LINEAR, GL.GL_LINEAR, GL.GL_CLAMP_TO_EDGE, GL.GL_CLAMP_TO_EDGE);
this.subTexImg.setARatioAdjustment(false);
this.drawLastSub = null;
}
/** Toggle enabling subtitle rendering */
public void setSubtitlesEnabled(final boolean v) {
subEnabled = v;
}
/**
* Sets text/ASS subtitle parameter, enabling subtitle rendering
* @param subFont text/ASS subtitle font, pass {@code null} for {@link FontFactory#getDefaultFont()}
* {@link FontFactory#getFallbackFont()} is used {@link Font#getBestCoverage(Font, Font, CharSequence) if providing a better coverage} of a Text/ASS subtitle line.
* @param subLineHeightPct text/ASS subtitle line height percentage, defaults to {@link #DEFAULT_ASS_SUB_HEIGHT}
* @param subLineDY text/ASS y-axis offset to bottom in line-height, defaults to {@link #DEFAULT_ASS_SUB_POS}
* @param subAlignment text/ASS subtitle alignment, defaults to {@link #DEFAULT_ASS_SUB_ALIGNMENT}
*/
public void setSubtitleParams(final Font subFont, final float subLineHeightPct, final float subLineDY, final Alignment subAlignment) {
if( null != subFont ) {
this.subFont = subFont;
} else {
this.subFont = FontFactory.getDefaultFont();
}
this.subFallbackFont = FontFactory.getFallbackFont();
this.subLineHeightPct = subLineHeightPct;
this.subLineDY = subLineDY;
this.subAlignment = subAlignment;
this.subEnabled = true;
}
/**
* Sets text/ASS subtitle colors
* @param color color for the text/ASS, defaults to {@link #DEFAULT_ASS_SUB_COLOR}
* @param blend blending alpha (darkness), defaults to {@link #DEFAULT_ASS_SUB_BLEND}
*/
public void setSubtitleColor(final Vec4f color, final float blend) {
this.subLabel.setColor( color );
this.subBlend.setColor( 0, 0, 0, blend );
}
public final SubtitleEventListener getSubEventListener() { return subEventListener; }
private final SubtitleEventListener subEventListener = new SubtitleEventListener() {
@Override
public void run(final SubtitleEvent e) {
synchronized( subEventLock ) {
subEventQueue.add(e);
if( DEBUG_SUB ) {
System.err.println("MediaButton: GOT #"+subEventQueue.size()+": "+e);
}
}
}
};
public MediaButton setVerbose(final boolean v) { verbose = v; return this; }
/**
* Add the default {@link GLMediaEventListener} to {@link #getGLMediaPlayer() this class's GLMediaPlayer}.
*/
public MediaButton addDefaultEventListener() {
getGLMediaPlayer().addEventListener(defGLMediaEventListener);
return this;
}
public final GLMediaPlayer getGLMediaPlayer() { return (GLMediaPlayer)texSeq; }
public final AudioSink getAudioSink() { return getGLMediaPlayer().getAudioSink(); }
private final GLMediaEventListener defGLMediaEventListener = new GLMediaEventListener() {
@Override
public void attributesChanged(final GLMediaPlayer mp, final GLMediaPlayer.EventMask eventMask, final long when) {
if( verbose ) {
System.err.println("MediaButton AttributesChanges: "+eventMask+", when "+when);
System.err.println("MediaButton State: "+mp);
}
if( eventMask.isSet(GLMediaPlayer.EventMask.Bit.Init) ) {
clearSubtitleCache();
resetGL = true;
} else if( eventMask.isSet(GLMediaPlayer.EventMask.Bit.Uninit) ||
eventMask.isSet(GLMediaPlayer.EventMask.Bit.Play) ||
eventMask.isSet(GLMediaPlayer.EventMask.Bit.Seek) ||
eventMask.isSet(GLMediaPlayer.EventMask.Bit.SID) ) {
clearSubtitleCache();
markStateDirty();
} else if( eventMask.isSet(GLMediaPlayer.EventMask.Bit.Pause) ) {
clearSubtitleCacheButLast();
markStateDirty();
}
if( eventMask.isSet(GLMediaPlayer.EventMask.Bit.Size) ) {
// FIXME: mPlayer.resetGLState();
}
if( eventMask.isSet(GLMediaPlayer.EventMask.Bit.EOS) ) {
new InterruptSource.Thread() {
@Override
public void run() {
// loop for-ever ..
mp.seek(0);
mp.resume();
} }.start();
} else if( eventMask.isSet(GLMediaPlayer.EventMask.Bit.Error) ) {
final StreamException se = mp.getStreamException();
if( null != se ) {
se.printStackTrace();
}
}
} };
@Override
protected void clearImpl(final GL2ES2 gl, final RegionRenderer renderer) {
((GLMediaPlayer)texSeq).stop();
((GLMediaPlayer)texSeq).seek(0);
clearSubtitleCache();
}
@Override
protected void destroyImpl(final GL2ES2 gl, final RegionRenderer renderer) {
((GLMediaPlayer)texSeq).stop();
clearSubtitleCache();
subTexImg.destroy(gl, renderer);
subLabel.destroy(gl, renderer);
subBlend.destroy(gl, renderer);
((GLMediaPlayer)texSeq).destroy(gl);
}
volatile boolean resetGL = true;
@Override
protected void addShapeToRegion(final GLProfile glp, final GL2ES2 gl) {
super.addShapeToRegion(glp, gl);
}
@Override
protected final void drawImpl0(final GL2ES2 gl, final RegionRenderer renderer, final Vec4f rgba) {
final GLMediaPlayer mPlayer = (GLMediaPlayer)texSeq;
if( resetGL ) {
resetGL = false;
try {
clearSubtitleCache();
mPlayer.initGL(gl);
if( null != region ) {
region.markShapeDirty(); // reset texture data
}
} catch (final Exception e) {
e.printStackTrace();
}
}
super.drawImpl0(gl, renderer, rgba);
if( subEnabled && GLMediaPlayer.STREAM_ID_NONE != mPlayer.getSID() ) {
drawSubtitle(gl, renderer);
}
if( GLMediaPlayer.State.Playing == mPlayer.getState() ) {
markStateDirty(); // keep on going
}
};
private final void clearSubtitleCache() {
synchronized( subEventLock ) {
final SubtitleEvent lastSub = drawLastSub;
drawLastSub = null;
if( null != lastSub ) {
lastSub.release();
}
subTexImg.getImageSequence().removeAllFrames();
for(final SubtitleEvent e : subEventQueue) {
e.release();
}
subEventQueue.clear();
if( DEBUG_SUB ) {
System.err.println("MediaButton.clearSubtitleCache: "+subEventQueue.size()+", last "+lastSub);
}
}
}
private final void clearSubtitleCacheButLast() {
synchronized( subEventLock ) {
final SubtitleEvent lastSub = drawLastSub;
for(int i=subEventQueue.size()-1; i>=0; --i) {
final SubtitleEvent e = subEventQueue.get(i);
if( lastSub != e ) {
e.release();
subEventQueue.remove(i);
}
}
if( DEBUG_SUB ) {
System.err.println("MediaButton.clearSubtitleCacheButLast: "+subEventQueue.size()+", last "+lastSub);
}
}
}
private final void drawSubtitle(final GL2ES2 gl, final RegionRenderer renderer) {
final GLMediaPlayer mPlayer = (GLMediaPlayer)texSeq;
final int pts = mPlayer.getPTS().getCurrent();
// Validate draw_lastSub timeout
SubtitleEvent lastSub = drawLastSub;
{
if( null != lastSub && lastSub.pts_end < pts ) {
if( DEBUG_SUB ) {
System.err.println("MediaButton: Drop.0: pts "+pts+", "+lastSub);
}
drawLastSub = null;
lastSub.release();
lastSub = null;
}
}
// Dequeue and earmark new subtitle in time.
// A new subtitle (empty as well) may simply replace an older one,
// allowing PGS subtitles to work (infinite end-time)
final SubtitleEvent sub;
final boolean newSub;
{
final SubtitleEvent gotSub;
synchronized( subEventLock ) {
if( subEventQueue.size() > 0 ) {
final SubtitleEvent e = subEventQueue.get(0);
if( e.pts_start <= pts && pts <= e.pts_end ) {
gotSub = e;
subEventQueue.remove(0);
} else if( e.pts_end < pts ) {
gotSub = null;
subEventQueue.remove(0);
e.release();
if( DEBUG_SUB ) {
System.err.println("MediaButton: Drop.1: pts "+pts+", "+e);
}
} else {
// subtitle for the future, keep it
gotSub = null;
}
} else {
gotSub = null;
}
}
if( null == gotSub ) {
sub = lastSub;
newSub = false;
} else {
if( null != lastSub ) {
lastSub.release();
}
lastSub = null;
if( SubtitleEvent.Type.Empty == gotSub.type ) {
gotSub.release();
sub = null;
newSub = false;
if( DEBUG_SUB ) {
System.err.println("MediaButton: Empty: pts "+pts+", "+gotSub);
}
} else {
drawLastSub = gotSub;
sub = gotSub;
newSub = true;
}
}
}
// drop or draw (update label for new subtitle)
if( null == sub ) {
drawLastSub = null;
} else if( SubtitleEvent.Type.Text == sub.type ) {
final SubTextEvent assSub = (SubTextEvent)sub;
if( newSub ) {
final float maxWidth = box.getWidth() * ASS_SUB_USED_WIDTH;
subLabel.setFont( Font.getBestCoverage(subFont, subFallbackFont, assSub.text) );
subLabel.setText(assSub.text);
int lines = assSub.lines;
AABBox subBox = subLabel.getBounds(gl.getGLProfile());
float subLineHeight = subBox.getHeight() / lines;
float lineHeight = box.getHeight() * subLineHeightPct;
float s_s = lineHeight / subLineHeight;
if( s_s * subBox.getWidth() > maxWidth ) {
// Split too wide text into multiple lines (max 4) fitting into box
// while trimming it beforehand.
if( DEBUG_SUB_LAYOUT ) {
System.err.println("XXX split.0 has lines "+lines+", s "+s_s+", width "+(s_s * subBox.getWidth())+" > "+maxWidth+": "+assSub.text);
}
final String trimmed = StringUtil.trim(assSub.text, StringUtil.WHITESPACE, " ");
lines = Math.min(ASS_SUB_MAX_SPLIT_LINES, (int)Math.ceil( s_s * subBox.getWidth() / maxWidth ));
final String text = StringUtil.split(trimmed, lines, " ", String.valueOf(StringUtil.LF));
lines = StringUtil.getLineCount(text);
subLabel.setText(text);
subBox = subLabel.getBounds(gl.getGLProfile());
subLineHeight = subBox.getHeight() / lines;
lineHeight = box.getHeight() * subLineHeightPct;
s_s = lineHeight / subLineHeight;
if( DEBUG_SUB_LAYOUT ) {
System.err.println("XXX split.X to lines "+lines+", s "+s_s+", width "+(s_s * subBox.getWidth())+" <=?= "+maxWidth+": "+text);
}
if( s_s * subBox.getWidth() > maxWidth ) {
// scale down remaining diff
s_s = maxWidth / subBox.getWidth();
lineHeight *= s_s / ( lineHeight / subLineHeight );
if( DEBUG_SUB_LAYOUT ) {
System.err.println("XXX scale-down scale "+s_s+", width "+(s_s * subBox.getWidth())+" <= "+maxWidth+": "+text);
}
}
}
subLabel.setScale(s_s, s_s, 1);
final float labelHeight = lineHeight * lines;
final float blendHeight = labelHeight + lineHeight * ASS_SUB_BLEND_ADDED_HEIGHT;
final Vec2f v_sz = new Vec2f(mPlayer.getWidth(), mPlayer.getHeight());
final Vec2f v_sxy = new Vec2f( box.getWidth(), box.getHeight() ).div( v_sz );
final float v_s = Math.min( v_sxy.x(), v_sxy.y() );
final Vec2f v_ctr = new Vec2f(v_sz).scale(0.5f); // original video size center
final Vec2f b_ctr = new Vec2f(box.getCenter()).scale(1/v_s);
final float d_bl = ( blendHeight - labelHeight ) * 0.5f;
final float v_maxWidth = v_sz.x() * ASS_SUB_USED_WIDTH;
final float d_vmw = v_sz.x() - v_maxWidth;
final Vec2f s_p0_s;
if( subAlignment.isSet( Alignment.Bit.Left) ) {
// Alignment.Bit.Left
final Vec2f s_p0 = new Vec2f( d_vmw*0.5f,
( subLineHeight * subLineDY * s_s ) / v_s);
s_p0_s = s_p0.sub( v_ctr ).add(b_ctr).scale( v_s ).add(0, d_bl);
} else {
// Alignment.Bit.CenterHoriz
final Vec2f s_p0 = new Vec2f( d_vmw*0.5f + ( v_maxWidth - subBox.getWidth()*s_s/v_s )*0.5f,
( subLineHeight * subLineDY * s_s ) / v_s);
s_p0_s = s_p0.sub( v_ctr ).add(b_ctr).scale( v_s ).add(0, d_bl);
}
subLabel.moveTo(s_p0_s.x(), s_p0_s.y(), 2*subZOffset);
subBlend.setDimension(box.getWidth(), blendHeight, 0f);
subBlend.setPosition(0, s_p0_s.y() - d_bl, 1*subZOffset);
if( DEBUG_SUB ) {
System.err.println("MediaButton: NEXT pts "+pts+", "+sub);
}
}
subBlend.draw(gl, renderer);
final PMVMatrix4f pmv = renderer.getMatrix();
pmv.pushMv();
subLabel.applyMatToMv(pmv);
subLabel.draw(gl, renderer);
pmv.popMv();
} else if( SubtitleEvent.Type.Bitmap == sub.type ) {
final SubBitmapEvent texSub = (SubBitmapEvent)sub;
if( newSub ) {
if( DEBUG_SUB ) {
System.err.println("MediaButton: NEXT pts "+pts+", "+sub);
}
if( null != texSub.texture ) {
final ImageSequence imgSeq = subTexImg.getImageSequence();
imgSeq.removeAllFrames();
imgSeq.addFrame(gl, texSub.texture);
final Vec2f v_sz = new Vec2f(mPlayer.getWidth(), mPlayer.getHeight());
final Vec2f v_sxy = new Vec2f( box.getWidth(), box.getHeight() ).div( v_sz );
final float v_s = Math.min(v_sxy.x(), v_sxy.y());
final Vec2f s_sz_s = new Vec2f(texSub.dimension).scale(v_s);
subTexImg.setSize(s_sz_s.x(), s_sz_s.y());
final Vec2f v_ctr;
if( CodecID.HDMV_PGS == sub.codec && mPlayer.getWidth() < 1920 && mPlayer.getHeight() == 1080 ) {
// PGS subtitles < 1920 width, e.g. 4:3 1440 width but 1080p
// usually are positioned to 1920 width screen. FIXME: Elaborate, find metrics
v_ctr = new Vec2f(new Vec2f(1920, 1080)).scale(0.5f); // 1080p center
} else {
v_ctr = new Vec2f(v_sz).scale(0.5f); // original video size center
}
final Vec2f b_ctr = new Vec2f(box.getCenter()).scale(1/v_s);
final Vec2f s_p0 = new Vec2f(texSub.position.x(),
v_sz.y() - texSub.position.y() - texSub.dimension.y() ); // y-flip + texSub.position is top-left
final Vec2f s_p0_s = s_p0.minus( v_ctr ).add( b_ctr ).scale( v_s );
subTexImg.moveTo(s_p0_s.x(), s_p0_s.y(), 2*subZOffset);
if( DEBUG_SUB_LAYOUT ) {
// Keep this to ease later adjustments due to specifications like PGS
final Vec2f b_sz = new Vec2f(box.getWidth(), box.getHeight());
final float v_ar = v_sz.x()/v_sz.y();
final float b_ar = b_sz.x()/b_sz.y();
final float s_ar = s_sz_s.x()/s_sz_s.y();
final float s_x_centered = ( b_sz.x() - s_sz_s.x() ) * 0.5f;
final Vec2f v_ctr_1080p = new Vec2f(new Vec2f(1920, 1080)).scale(0.5f); // 1080p center
final Vec2f v_ctr_o = new Vec2f(v_sz).scale(0.5f); // original video size center
final Vec2f s_sz = new Vec2f(texSub.dimension);
final Vec2f b_ctr_s = new Vec2f(box.getCenter());
final Vec2f v_p0_ctr = s_p0.minus(v_ctr); // p0 -> v_sz center
final Vec2f s_p1 = b_ctr.plus(v_p0_ctr);
System.err.println("XX video "+v_sz+" (ar "+v_ar+"), ( v_ctr "+v_ctr_o+", v_ctr_1080p "+v_ctr_1080p+" ) -> v_ctr "+v_ctr);
System.err.println("XX sub s_sz "+s_sz+", s_sz_s "+s_sz_s+" (ar "+s_ar+")");
System.err.println("XX box "+b_sz+" (ar "+b_ar+"), b_ctr "+b_ctr+", b_ctr_s "+b_ctr_s);
System.err.println("XXX v_s "+v_sxy+" -> "+v_s+": sz "+s_sz_s);
System.err.println("XXX p0 "+s_p0+", v_p0_ctr "+v_p0_ctr+", s_p1 "+s_p1+" -> s_p1_s "+s_p0_s+"; sxs_2 "+s_x_centered);
}
}
}
final PMVMatrix4f pmv = renderer.getMatrix();
pmv.pushMv();
subTexImg.applyMatToMv(pmv);
subTexImg.draw(gl, renderer);
pmv.popMv();
}
}
}